vitek-plugin 0.1.2-beta → 0.1.2-beta.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 +33 -3
- package/dist/adapters/vite/dev-server.d.ts +2 -0
- package/dist/adapters/vite/dev-server.d.ts.map +1 -1
- package/dist/adapters/vite/dev-server.js +34 -0
- package/dist/core/middleware/compose.test.d.ts +2 -0
- package/dist/core/middleware/compose.test.d.ts.map +1 -0
- package/dist/core/middleware/compose.test.js +177 -0
- package/dist/core/normalize/normalize-path.test.d.ts +2 -0
- package/dist/core/normalize/normalize-path.test.d.ts.map +1 -0
- package/dist/core/normalize/normalize-path.test.js +130 -0
- package/dist/core/openapi/generate.d.ts +65 -0
- package/dist/core/openapi/generate.d.ts.map +1 -0
- package/dist/core/openapi/generate.js +464 -0
- package/dist/core/routing/route-matcher.test.d.ts +2 -0
- package/dist/core/routing/route-matcher.test.d.ts.map +1 -0
- package/dist/core/routing/route-matcher.test.js +231 -0
- package/dist/core/routing/route-parser.test.d.ts +2 -0
- package/dist/core/routing/route-parser.test.d.ts.map +1 -0
- package/dist/core/routing/route-parser.test.js +142 -0
- package/dist/core/server/request-handler.d.ts.map +1 -1
- package/dist/core/server/request-handler.js +5 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/plugin.d.ts +3 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +3 -1
- package/dist/shared/errors.test.d.ts +2 -0
- package/dist/shared/errors.test.d.ts.map +1 -0
- package/dist/shared/errors.test.js +174 -0
- package/dist/shared/response-helpers.test.d.ts +2 -0
- package/dist/shared/response-helpers.test.d.ts.map +1 -0
- package/dist/shared/response-helpers.test.js +167 -0
- package/package.json +10 -5
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAPI/Swagger specification generation
|
|
3
|
+
* Core logic - no Vite dependencies
|
|
4
|
+
*/
|
|
5
|
+
import * as fs from 'fs';
|
|
6
|
+
import * as path from 'path';
|
|
7
|
+
/** Default OpenAPI info values */
|
|
8
|
+
const DEFAULT_OPENAPI_INFO = {
|
|
9
|
+
title: 'Vitek API',
|
|
10
|
+
version: '1.0.0',
|
|
11
|
+
description: 'Auto-generated API documentation',
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Generates a complete OpenAPI 3.0 specification
|
|
15
|
+
*/
|
|
16
|
+
export function generateOpenApiSpec(routes, options) {
|
|
17
|
+
const info = { ...DEFAULT_OPENAPI_INFO, ...options.info };
|
|
18
|
+
const spec = {
|
|
19
|
+
openapi: '3.0.3',
|
|
20
|
+
info,
|
|
21
|
+
paths: generatePaths(routes, options),
|
|
22
|
+
components: {
|
|
23
|
+
schemas: generateSchemas(routes),
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
if (options.servers && options.servers.length > 0) {
|
|
27
|
+
spec.servers = options.servers;
|
|
28
|
+
}
|
|
29
|
+
return spec;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Generates OpenAPI paths from routes
|
|
33
|
+
*/
|
|
34
|
+
function generatePaths(routes, options) {
|
|
35
|
+
const paths = {};
|
|
36
|
+
for (const route of routes) {
|
|
37
|
+
const openApiPath = convertPatternToOpenApi(route.pattern);
|
|
38
|
+
const metadata = extractMetadataFromFile(route.file);
|
|
39
|
+
if (!paths[openApiPath]) {
|
|
40
|
+
paths[openApiPath] = {};
|
|
41
|
+
}
|
|
42
|
+
const pathItem = paths[openApiPath];
|
|
43
|
+
pathItem[route.method] = generateOperationObject(route, metadata, options);
|
|
44
|
+
}
|
|
45
|
+
return paths;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Converts Vitek pattern to OpenAPI path format
|
|
49
|
+
* Example: "users/:id" -> "/users/{id}"
|
|
50
|
+
* Example: "posts/*ids" -> "/posts/{ids}"
|
|
51
|
+
*/
|
|
52
|
+
function convertPatternToOpenApi(pattern) {
|
|
53
|
+
if (!pattern || pattern === '') {
|
|
54
|
+
return '/';
|
|
55
|
+
}
|
|
56
|
+
// Convert :param to {param} and *param to {param}
|
|
57
|
+
return '/' + pattern
|
|
58
|
+
.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, '{$1}')
|
|
59
|
+
.replace(/\*([a-zA-Z_][a-zA-Z0-9_]*)/g, '{$1}');
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Generates an OpenAPI Operation object for a route
|
|
63
|
+
*/
|
|
64
|
+
function generateOperationObject(route, metadata, options) {
|
|
65
|
+
const operation = {
|
|
66
|
+
operationId: generateOperationId(route),
|
|
67
|
+
summary: metadata.summary || `${route.method.toUpperCase()} ${route.pattern}`,
|
|
68
|
+
};
|
|
69
|
+
if (metadata.description) {
|
|
70
|
+
operation.description = metadata.description;
|
|
71
|
+
}
|
|
72
|
+
if (metadata.tags && metadata.tags.length > 0) {
|
|
73
|
+
operation.tags = metadata.tags;
|
|
74
|
+
}
|
|
75
|
+
if (metadata.deprecated) {
|
|
76
|
+
operation.deprecated = true;
|
|
77
|
+
}
|
|
78
|
+
// Parameters (path, query)
|
|
79
|
+
const parameters = [];
|
|
80
|
+
// Path parameters
|
|
81
|
+
for (const param of route.params) {
|
|
82
|
+
parameters.push({
|
|
83
|
+
name: param,
|
|
84
|
+
in: 'path',
|
|
85
|
+
required: true,
|
|
86
|
+
schema: { type: 'string' },
|
|
87
|
+
description: metadata.paramDescriptions?.[param] || undefined,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
// Query parameters (if queryType exists)
|
|
91
|
+
if (route.queryType) {
|
|
92
|
+
const queryFields = extractTypeFields(route.queryType);
|
|
93
|
+
for (const field of queryFields) {
|
|
94
|
+
parameters.push({
|
|
95
|
+
name: field.name,
|
|
96
|
+
in: 'query',
|
|
97
|
+
required: field.required,
|
|
98
|
+
schema: field.schema,
|
|
99
|
+
description: field.description,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (parameters.length > 0) {
|
|
104
|
+
operation.parameters = parameters;
|
|
105
|
+
}
|
|
106
|
+
// Request body (if bodyType exists and method supports body)
|
|
107
|
+
if (route.bodyType && ['post', 'put', 'patch'].includes(route.method)) {
|
|
108
|
+
operation.requestBody = {
|
|
109
|
+
description: metadata.bodyDescription || 'Request body',
|
|
110
|
+
required: true,
|
|
111
|
+
content: {
|
|
112
|
+
'application/json': {
|
|
113
|
+
schema: typeToSchema(route.bodyType),
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
// Responses
|
|
119
|
+
operation.responses = generateResponses(route, metadata);
|
|
120
|
+
return operation;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Generates a unique operation ID from route
|
|
124
|
+
*/
|
|
125
|
+
function generateOperationId(route) {
|
|
126
|
+
const patternParts = route.pattern
|
|
127
|
+
.split('/')
|
|
128
|
+
.filter(Boolean)
|
|
129
|
+
.map(part => {
|
|
130
|
+
// Clean parameter markers
|
|
131
|
+
const clean = part.replace(/^[:*]/, '');
|
|
132
|
+
// Capitalize
|
|
133
|
+
return clean.charAt(0).toUpperCase() + clean.slice(1);
|
|
134
|
+
});
|
|
135
|
+
return route.method + patternParts.join('');
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Generates OpenAPI responses object
|
|
139
|
+
*/
|
|
140
|
+
function generateResponses(route, metadata) {
|
|
141
|
+
const responses = {};
|
|
142
|
+
if (metadata.responses && Object.keys(metadata.responses).length > 0) {
|
|
143
|
+
// Use JSDoc @response annotations
|
|
144
|
+
for (const [code, responseMeta] of Object.entries(metadata.responses)) {
|
|
145
|
+
responses[code] = {
|
|
146
|
+
description: responseMeta.description,
|
|
147
|
+
content: responseMeta.type ? {
|
|
148
|
+
'application/json': {
|
|
149
|
+
schema: typeStringToJsonSchema(responseMeta.type),
|
|
150
|
+
example: responseMeta.example,
|
|
151
|
+
},
|
|
152
|
+
} : undefined,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
// Default responses based on method
|
|
158
|
+
switch (route.method) {
|
|
159
|
+
case 'get':
|
|
160
|
+
responses['200'] = {
|
|
161
|
+
description: 'Successful response',
|
|
162
|
+
content: {
|
|
163
|
+
'application/json': {
|
|
164
|
+
schema: { type: 'object' },
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
break;
|
|
169
|
+
case 'post':
|
|
170
|
+
responses['201'] = {
|
|
171
|
+
description: 'Created successfully',
|
|
172
|
+
content: {
|
|
173
|
+
'application/json': {
|
|
174
|
+
schema: { type: 'object' },
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
break;
|
|
179
|
+
case 'put':
|
|
180
|
+
case 'patch':
|
|
181
|
+
responses['200'] = {
|
|
182
|
+
description: 'Updated successfully',
|
|
183
|
+
content: {
|
|
184
|
+
'application/json': {
|
|
185
|
+
schema: { type: 'object' },
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
break;
|
|
190
|
+
case 'delete':
|
|
191
|
+
responses['204'] = {
|
|
192
|
+
description: 'Deleted successfully',
|
|
193
|
+
};
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// Always add error responses
|
|
198
|
+
responses['400'] = { description: 'Bad request' };
|
|
199
|
+
responses['401'] = { description: 'Unauthorized' };
|
|
200
|
+
responses['404'] = { description: 'Not found' };
|
|
201
|
+
responses['500'] = { description: 'Internal server error' };
|
|
202
|
+
return responses;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Extracts metadata from JSDoc comments in a route file
|
|
206
|
+
*/
|
|
207
|
+
function extractMetadataFromFile(filePath) {
|
|
208
|
+
try {
|
|
209
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
210
|
+
const metadata = {};
|
|
211
|
+
// Find JSDoc comment before default export or handler
|
|
212
|
+
const jsdocRegex = /\/\*\*([\s\S]*?)\*\/\s*(?:export\s+default|export\s+(?:async\s+)?function|const\s+handler)/;
|
|
213
|
+
const match = content.match(jsdocRegex);
|
|
214
|
+
if (!match) {
|
|
215
|
+
return metadata;
|
|
216
|
+
}
|
|
217
|
+
const jsdoc = match[1];
|
|
218
|
+
// Extract @summary or first line as summary
|
|
219
|
+
const summaryMatch = jsdoc.match(/@summary\s+(.+)$/m);
|
|
220
|
+
if (summaryMatch) {
|
|
221
|
+
metadata.summary = summaryMatch[1].trim();
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
// First non-tag line is description/summary
|
|
225
|
+
const descMatch = jsdoc.match(/\*\s+([^@\n].+)$/m);
|
|
226
|
+
if (descMatch) {
|
|
227
|
+
metadata.summary = descMatch[1].trim();
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
// Extract @description
|
|
231
|
+
const descriptionMatch = jsdoc.match(/@description\s+([\s\S]*?)(?=\s*@|\s*\*\/|$)/);
|
|
232
|
+
if (descriptionMatch) {
|
|
233
|
+
metadata.description = descriptionMatch[1]
|
|
234
|
+
.split('\n')
|
|
235
|
+
.map(line => line.replace(/^\s*\*\s?/, '').trim())
|
|
236
|
+
.join(' ')
|
|
237
|
+
.trim();
|
|
238
|
+
}
|
|
239
|
+
// Extract @tag
|
|
240
|
+
const tagMatches = jsdoc.matchAll(/@tag\s+(\w+)/g);
|
|
241
|
+
metadata.tags = Array.from(tagMatches).map(m => m[1]);
|
|
242
|
+
// Extract @deprecated
|
|
243
|
+
metadata.deprecated = /@deprecated/.test(jsdoc);
|
|
244
|
+
// Extract @response
|
|
245
|
+
const responseMatches = jsdoc.matchAll(/@response\s+(\d+)\s+(.+?)(?:\s+-\s*(\{[^}]+\}))?(?:\s+-\s*(.+))?$/gm);
|
|
246
|
+
metadata.responses = {};
|
|
247
|
+
for (const m of responseMatches) {
|
|
248
|
+
const code = m[1];
|
|
249
|
+
const description = m[2]?.trim();
|
|
250
|
+
const type = m[3]?.replace(/[{}]/g, '').trim();
|
|
251
|
+
const exampleStr = m[4]?.trim();
|
|
252
|
+
metadata.responses[code] = {
|
|
253
|
+
description,
|
|
254
|
+
type,
|
|
255
|
+
example: exampleStr ? tryParseJson(exampleStr) : undefined,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
// Extract @param for parameter descriptions
|
|
259
|
+
const paramMatches = jsdoc.matchAll(/@param\s+(?:\{[^}]+\}\s+)?(\w+)\s+-\s*(.+)$/gm);
|
|
260
|
+
metadata.paramDescriptions = {};
|
|
261
|
+
for (const m of paramMatches) {
|
|
262
|
+
metadata.paramDescriptions[m[1]] = m[2].trim();
|
|
263
|
+
}
|
|
264
|
+
// Extract @bodyDescription
|
|
265
|
+
const bodyDescMatch = jsdoc.match(/@bodyDescription\s+(.+)$/m);
|
|
266
|
+
if (bodyDescMatch) {
|
|
267
|
+
metadata.bodyDescription = bodyDescMatch[1].trim();
|
|
268
|
+
}
|
|
269
|
+
return metadata;
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
return {};
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Try to parse a string as JSON
|
|
277
|
+
*/
|
|
278
|
+
function tryParseJson(str) {
|
|
279
|
+
try {
|
|
280
|
+
return JSON.parse(str);
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
return str;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Extracts fields from a type definition string
|
|
288
|
+
*/
|
|
289
|
+
function extractTypeFields(typeStr) {
|
|
290
|
+
const fields = [];
|
|
291
|
+
// Parse object type like { limit?: number; offset?: number }
|
|
292
|
+
const cleanType = typeStr.replace(/^[\s\n]+/, '').replace(/[\s\n]+$/, '');
|
|
293
|
+
if (!cleanType.startsWith('{')) {
|
|
294
|
+
return fields;
|
|
295
|
+
}
|
|
296
|
+
// Remove outer braces
|
|
297
|
+
const inner = cleanType.slice(1, -1).trim();
|
|
298
|
+
// Split by semicolons (simplistic parsing)
|
|
299
|
+
const propLines = inner.split(';').filter(s => s.trim());
|
|
300
|
+
for (const line of propLines) {
|
|
301
|
+
const trimmed = line.trim();
|
|
302
|
+
if (!trimmed)
|
|
303
|
+
continue;
|
|
304
|
+
// Match: name?: type or name: type
|
|
305
|
+
const match = trimmed.match(/^(\w+)(\?)?:\s*(.+)$/);
|
|
306
|
+
if (match) {
|
|
307
|
+
const name = match[1];
|
|
308
|
+
const optional = !!match[2];
|
|
309
|
+
const type = match[3].trim();
|
|
310
|
+
fields.push({
|
|
311
|
+
name,
|
|
312
|
+
required: !optional,
|
|
313
|
+
schema: typeStringToJsonSchema(type),
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return fields;
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Converts a type string to JSON Schema
|
|
321
|
+
*/
|
|
322
|
+
function typeStringToJsonSchema(type) {
|
|
323
|
+
const cleanType = type.trim();
|
|
324
|
+
// Array types
|
|
325
|
+
if (cleanType.endsWith('[]')) {
|
|
326
|
+
const itemType = cleanType.slice(0, -2);
|
|
327
|
+
return {
|
|
328
|
+
type: 'array',
|
|
329
|
+
items: typeStringToJsonSchema(itemType),
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
// Union types (simplified)
|
|
333
|
+
if (cleanType.includes('|')) {
|
|
334
|
+
const types = cleanType.split('|').map(t => t.trim());
|
|
335
|
+
const schemas = types.map(t => typeStringToJsonSchema(t));
|
|
336
|
+
return { anyOf: schemas };
|
|
337
|
+
}
|
|
338
|
+
// Basic types
|
|
339
|
+
switch (cleanType.toLowerCase()) {
|
|
340
|
+
case 'string':
|
|
341
|
+
return { type: 'string' };
|
|
342
|
+
case 'number':
|
|
343
|
+
case 'integer':
|
|
344
|
+
return { type: 'number' };
|
|
345
|
+
case 'boolean':
|
|
346
|
+
return { type: 'boolean' };
|
|
347
|
+
case 'date':
|
|
348
|
+
return { type: 'string', format: 'date-time' };
|
|
349
|
+
case 'object':
|
|
350
|
+
return { type: 'object' };
|
|
351
|
+
case 'any':
|
|
352
|
+
return {};
|
|
353
|
+
default:
|
|
354
|
+
// Could be a custom type reference
|
|
355
|
+
if (cleanType.match(/^[A-Z]\w*$/)) {
|
|
356
|
+
return { $ref: `#/components/schemas/${cleanType}` };
|
|
357
|
+
}
|
|
358
|
+
return { type: 'object' };
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Converts a type definition to JSON Schema
|
|
363
|
+
*/
|
|
364
|
+
function typeToSchema(typeStr) {
|
|
365
|
+
if (typeStr.trim().startsWith('{')) {
|
|
366
|
+
const fields = extractTypeFields(typeStr);
|
|
367
|
+
const properties = {};
|
|
368
|
+
const required = [];
|
|
369
|
+
for (const field of fields) {
|
|
370
|
+
properties[field.name] = field.schema;
|
|
371
|
+
if (field.required) {
|
|
372
|
+
required.push(field.name);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return {
|
|
376
|
+
type: 'object',
|
|
377
|
+
properties,
|
|
378
|
+
required: required.length > 0 ? required : undefined,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
return typeStringToJsonSchema(typeStr);
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Generates component schemas from route types
|
|
385
|
+
*/
|
|
386
|
+
function generateSchemas(routes) {
|
|
387
|
+
const schemas = {};
|
|
388
|
+
// Collect unique type names referenced in routes
|
|
389
|
+
const typeNames = new Set();
|
|
390
|
+
for (const route of routes) {
|
|
391
|
+
if (route.bodyType) {
|
|
392
|
+
const refs = extractTypeReferences(route.bodyType);
|
|
393
|
+
refs.forEach(ref => typeNames.add(ref));
|
|
394
|
+
}
|
|
395
|
+
if (route.queryType) {
|
|
396
|
+
const refs = extractTypeReferences(route.queryType);
|
|
397
|
+
refs.forEach(ref => typeNames.add(ref));
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
// For now, create placeholder schemas
|
|
401
|
+
// In a future version, we could parse actual type definitions from files
|
|
402
|
+
for (const typeName of typeNames) {
|
|
403
|
+
schemas[typeName] = {
|
|
404
|
+
type: 'object',
|
|
405
|
+
description: `${typeName} schema`,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
return schemas;
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Extracts type references from a type string
|
|
412
|
+
*/
|
|
413
|
+
function extractTypeReferences(typeStr) {
|
|
414
|
+
const refs = [];
|
|
415
|
+
// Match capitalized type names (User, Post, etc.)
|
|
416
|
+
const matches = typeStr.matchAll(/\b([A-Z][a-zA-Z0-9_]*)\b/g);
|
|
417
|
+
for (const match of matches) {
|
|
418
|
+
// Exclude primitive-like names
|
|
419
|
+
if (!['Object', 'Array', 'Date', 'String', 'Number', 'Boolean'].includes(match[1])) {
|
|
420
|
+
refs.push(match[1]);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return [...new Set(refs)];
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Generates the OpenAPI JSON file
|
|
427
|
+
*/
|
|
428
|
+
export async function generateOpenApiFile(outputPath, routes, options) {
|
|
429
|
+
const spec = generateOpenApiSpec(routes, options);
|
|
430
|
+
// Create directory if it doesn't exist
|
|
431
|
+
const dir = path.dirname(outputPath);
|
|
432
|
+
if (!fs.existsSync(dir)) {
|
|
433
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
434
|
+
}
|
|
435
|
+
fs.writeFileSync(outputPath, JSON.stringify(spec, null, 2), 'utf-8');
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Generates Swagger UI HTML
|
|
439
|
+
*/
|
|
440
|
+
export function generateSwaggerUiHtml(apiJsonPath, title = 'API Documentation') {
|
|
441
|
+
return `<!DOCTYPE html>
|
|
442
|
+
<html lang="en">
|
|
443
|
+
<head>
|
|
444
|
+
<meta charset="UTF-8">
|
|
445
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
446
|
+
<title>${title}</title>
|
|
447
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css">
|
|
448
|
+
</head>
|
|
449
|
+
<body>
|
|
450
|
+
<div id="swagger-ui"></div>
|
|
451
|
+
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
|
452
|
+
<script>
|
|
453
|
+
SwaggerUIBundle({
|
|
454
|
+
url: '${apiJsonPath}',
|
|
455
|
+
dom_id: '#swagger-ui',
|
|
456
|
+
presets: [
|
|
457
|
+
SwaggerUIBundle.presets.apis,
|
|
458
|
+
SwaggerUIBundle.presets.standalone
|
|
459
|
+
]
|
|
460
|
+
});
|
|
461
|
+
</script>
|
|
462
|
+
</body>
|
|
463
|
+
</html>`;
|
|
464
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"route-matcher.test.d.ts","sourceRoot":"","sources":["../../../src/core/routing/route-matcher.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { matchRoute } from './route-matcher.js';
|
|
3
|
+
import { createRoute } from './route-parser.js';
|
|
4
|
+
function createTestRoute(parsed) {
|
|
5
|
+
return createRoute(parsed, async () => ({ success: true }));
|
|
6
|
+
}
|
|
7
|
+
describe('matchRoute', () => {
|
|
8
|
+
describe('basic matching', () => {
|
|
9
|
+
it('should match exact path', () => {
|
|
10
|
+
const routes = [
|
|
11
|
+
createTestRoute({
|
|
12
|
+
method: 'get',
|
|
13
|
+
pattern: 'health',
|
|
14
|
+
params: [],
|
|
15
|
+
file: '/api/health.get.ts',
|
|
16
|
+
}),
|
|
17
|
+
];
|
|
18
|
+
const match = matchRoute(routes, '/health', 'get');
|
|
19
|
+
expect(match).not.toBeNull();
|
|
20
|
+
expect(match?.route.pattern).toBe('health');
|
|
21
|
+
expect(match?.params).toEqual({});
|
|
22
|
+
});
|
|
23
|
+
it('should match path with leading slash', () => {
|
|
24
|
+
const routes = [
|
|
25
|
+
createTestRoute({
|
|
26
|
+
method: 'get',
|
|
27
|
+
pattern: 'health',
|
|
28
|
+
params: [],
|
|
29
|
+
file: '/api/health.get.ts',
|
|
30
|
+
}),
|
|
31
|
+
];
|
|
32
|
+
const match = matchRoute(routes, 'health', 'get');
|
|
33
|
+
expect(match).not.toBeNull();
|
|
34
|
+
expect(match?.route.pattern).toBe('health');
|
|
35
|
+
});
|
|
36
|
+
it('should return null for non-matching path', () => {
|
|
37
|
+
const routes = [
|
|
38
|
+
createTestRoute({
|
|
39
|
+
method: 'get',
|
|
40
|
+
pattern: 'health',
|
|
41
|
+
params: [],
|
|
42
|
+
file: '/api/health.get.ts',
|
|
43
|
+
}),
|
|
44
|
+
];
|
|
45
|
+
const match = matchRoute(routes, '/users', 'get');
|
|
46
|
+
expect(match).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
it('should return null for non-matching method', () => {
|
|
49
|
+
const routes = [
|
|
50
|
+
createTestRoute({
|
|
51
|
+
method: 'get',
|
|
52
|
+
pattern: 'health',
|
|
53
|
+
params: [],
|
|
54
|
+
file: '/api/health.get.ts',
|
|
55
|
+
}),
|
|
56
|
+
];
|
|
57
|
+
const match = matchRoute(routes, '/health', 'post');
|
|
58
|
+
expect(match).toBeNull();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
describe('dynamic parameters', () => {
|
|
62
|
+
it('should extract single parameter', () => {
|
|
63
|
+
const routes = [
|
|
64
|
+
createTestRoute({
|
|
65
|
+
method: 'get',
|
|
66
|
+
pattern: 'users/:id',
|
|
67
|
+
params: ['id'],
|
|
68
|
+
file: '/api/users/[id].get.ts',
|
|
69
|
+
}),
|
|
70
|
+
];
|
|
71
|
+
const match = matchRoute(routes, '/users/123', 'get');
|
|
72
|
+
expect(match).not.toBeNull();
|
|
73
|
+
expect(match?.params).toEqual({ id: '123' });
|
|
74
|
+
});
|
|
75
|
+
it('should extract multiple parameters', () => {
|
|
76
|
+
const routes = [
|
|
77
|
+
createTestRoute({
|
|
78
|
+
method: 'get',
|
|
79
|
+
pattern: 'users/:userId/posts/:postId',
|
|
80
|
+
params: ['userId', 'postId'],
|
|
81
|
+
file: '/api/users/[userId]/posts/[postId].get.ts',
|
|
82
|
+
}),
|
|
83
|
+
];
|
|
84
|
+
const match = matchRoute(routes, '/users/42/posts/99', 'get');
|
|
85
|
+
expect(match).not.toBeNull();
|
|
86
|
+
expect(match?.params).toEqual({ userId: '42', postId: '99' });
|
|
87
|
+
});
|
|
88
|
+
it('should handle parameters with special characters', () => {
|
|
89
|
+
const routes = [
|
|
90
|
+
createTestRoute({
|
|
91
|
+
method: 'get',
|
|
92
|
+
pattern: 'files/:filename',
|
|
93
|
+
params: ['filename'],
|
|
94
|
+
file: '/api/files/[filename].get.ts',
|
|
95
|
+
}),
|
|
96
|
+
];
|
|
97
|
+
const match = matchRoute(routes, '/files/document.pdf', 'get');
|
|
98
|
+
expect(match).not.toBeNull();
|
|
99
|
+
expect(match?.params).toEqual({ filename: 'document.pdf' });
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
describe('catch-all parameters', () => {
|
|
103
|
+
it('should match catch-all route', () => {
|
|
104
|
+
const routes = [
|
|
105
|
+
createTestRoute({
|
|
106
|
+
method: 'get',
|
|
107
|
+
pattern: 'files/*path',
|
|
108
|
+
params: ['path'],
|
|
109
|
+
file: '/api/files/[...path].get.ts',
|
|
110
|
+
}),
|
|
111
|
+
];
|
|
112
|
+
const match = matchRoute(routes, '/files/docs/folder/file.txt', 'get');
|
|
113
|
+
expect(match).not.toBeNull();
|
|
114
|
+
expect(match?.params).toEqual({ path: 'docs/folder/file.txt' });
|
|
115
|
+
});
|
|
116
|
+
it('should match catch-all with single segment', () => {
|
|
117
|
+
const routes = [
|
|
118
|
+
createTestRoute({
|
|
119
|
+
method: 'get',
|
|
120
|
+
pattern: 'api/*rest',
|
|
121
|
+
params: ['rest'],
|
|
122
|
+
file: '/api/api/[...rest].get.ts',
|
|
123
|
+
}),
|
|
124
|
+
];
|
|
125
|
+
const match = matchRoute(routes, '/api/users', 'get');
|
|
126
|
+
expect(match).not.toBeNull();
|
|
127
|
+
expect(match?.params).toEqual({ rest: 'users' });
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
describe('method case insensitivity', () => {
|
|
131
|
+
it('should match uppercase methods', () => {
|
|
132
|
+
const routes = [
|
|
133
|
+
createTestRoute({
|
|
134
|
+
method: 'get',
|
|
135
|
+
pattern: 'health',
|
|
136
|
+
params: [],
|
|
137
|
+
file: '/api/health.get.ts',
|
|
138
|
+
}),
|
|
139
|
+
];
|
|
140
|
+
const match = matchRoute(routes, '/health', 'GET');
|
|
141
|
+
expect(match).not.toBeNull();
|
|
142
|
+
});
|
|
143
|
+
it('should match mixed case methods', () => {
|
|
144
|
+
const routes = [
|
|
145
|
+
createTestRoute({
|
|
146
|
+
method: 'post',
|
|
147
|
+
pattern: 'users',
|
|
148
|
+
params: [],
|
|
149
|
+
file: '/api/users.post.ts',
|
|
150
|
+
}),
|
|
151
|
+
];
|
|
152
|
+
const match = matchRoute(routes, '/users', 'Post');
|
|
153
|
+
expect(match).not.toBeNull();
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
describe('multiple routes', () => {
|
|
157
|
+
it('should return first matching route', () => {
|
|
158
|
+
const routes = [
|
|
159
|
+
createTestRoute({
|
|
160
|
+
method: 'get',
|
|
161
|
+
pattern: 'users/:id',
|
|
162
|
+
params: ['id'],
|
|
163
|
+
file: '/api/users/[id].get.ts',
|
|
164
|
+
}),
|
|
165
|
+
createTestRoute({
|
|
166
|
+
method: 'get',
|
|
167
|
+
pattern: 'users/me',
|
|
168
|
+
params: [],
|
|
169
|
+
file: '/api/users/me.get.ts',
|
|
170
|
+
}),
|
|
171
|
+
];
|
|
172
|
+
// This will match the first route with params.id = 'me'
|
|
173
|
+
const match = matchRoute(routes, '/users/me', 'get');
|
|
174
|
+
expect(match).not.toBeNull();
|
|
175
|
+
expect(match?.route.pattern).toBe('users/:id');
|
|
176
|
+
expect(match?.params).toEqual({ id: 'me' });
|
|
177
|
+
});
|
|
178
|
+
it('should filter by method before matching', () => {
|
|
179
|
+
const routes = [
|
|
180
|
+
createTestRoute({
|
|
181
|
+
method: 'get',
|
|
182
|
+
pattern: 'users',
|
|
183
|
+
params: [],
|
|
184
|
+
file: '/api/users.get.ts',
|
|
185
|
+
}),
|
|
186
|
+
createTestRoute({
|
|
187
|
+
method: 'post',
|
|
188
|
+
pattern: 'users',
|
|
189
|
+
params: [],
|
|
190
|
+
file: '/api/users.post.ts',
|
|
191
|
+
}),
|
|
192
|
+
];
|
|
193
|
+
const getMatch = matchRoute(routes, '/users', 'get');
|
|
194
|
+
const postMatch = matchRoute(routes, '/users', 'post');
|
|
195
|
+
expect(getMatch?.route.method).toBe('get');
|
|
196
|
+
expect(postMatch?.route.method).toBe('post');
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
describe('edge cases', () => {
|
|
200
|
+
it('should handle empty routes array', () => {
|
|
201
|
+
const match = matchRoute([], '/health', 'get');
|
|
202
|
+
expect(match).toBeNull();
|
|
203
|
+
});
|
|
204
|
+
it('should handle root path', () => {
|
|
205
|
+
const routes = [
|
|
206
|
+
createTestRoute({
|
|
207
|
+
method: 'get',
|
|
208
|
+
pattern: '',
|
|
209
|
+
params: [],
|
|
210
|
+
file: '/api/index.get.ts',
|
|
211
|
+
}),
|
|
212
|
+
];
|
|
213
|
+
const match = matchRoute(routes, '/', 'get');
|
|
214
|
+
expect(match).not.toBeNull();
|
|
215
|
+
expect(match?.route.pattern).toBe('');
|
|
216
|
+
});
|
|
217
|
+
it('should handle empty parameter values', () => {
|
|
218
|
+
const routes = [
|
|
219
|
+
createTestRoute({
|
|
220
|
+
method: 'get',
|
|
221
|
+
pattern: 'users/:id',
|
|
222
|
+
params: ['id'],
|
|
223
|
+
file: '/api/users/[id].get.ts',
|
|
224
|
+
}),
|
|
225
|
+
];
|
|
226
|
+
// Edge case: URL with empty segment
|
|
227
|
+
const match = matchRoute(routes, '/users/', 'get');
|
|
228
|
+
expect(match).toBeNull();
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
});
|