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.
Files changed (33) hide show
  1. package/README.md +33 -3
  2. package/dist/adapters/vite/dev-server.d.ts +2 -0
  3. package/dist/adapters/vite/dev-server.d.ts.map +1 -1
  4. package/dist/adapters/vite/dev-server.js +34 -0
  5. package/dist/core/middleware/compose.test.d.ts +2 -0
  6. package/dist/core/middleware/compose.test.d.ts.map +1 -0
  7. package/dist/core/middleware/compose.test.js +177 -0
  8. package/dist/core/normalize/normalize-path.test.d.ts +2 -0
  9. package/dist/core/normalize/normalize-path.test.d.ts.map +1 -0
  10. package/dist/core/normalize/normalize-path.test.js +130 -0
  11. package/dist/core/openapi/generate.d.ts +65 -0
  12. package/dist/core/openapi/generate.d.ts.map +1 -0
  13. package/dist/core/openapi/generate.js +464 -0
  14. package/dist/core/routing/route-matcher.test.d.ts +2 -0
  15. package/dist/core/routing/route-matcher.test.d.ts.map +1 -0
  16. package/dist/core/routing/route-matcher.test.js +231 -0
  17. package/dist/core/routing/route-parser.test.d.ts +2 -0
  18. package/dist/core/routing/route-parser.test.d.ts.map +1 -0
  19. package/dist/core/routing/route-parser.test.js +142 -0
  20. package/dist/core/server/request-handler.d.ts.map +1 -1
  21. package/dist/core/server/request-handler.js +5 -2
  22. package/dist/index.d.ts +1 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/plugin.d.ts +3 -0
  25. package/dist/plugin.d.ts.map +1 -1
  26. package/dist/plugin.js +3 -1
  27. package/dist/shared/errors.test.d.ts +2 -0
  28. package/dist/shared/errors.test.d.ts.map +1 -0
  29. package/dist/shared/errors.test.js +174 -0
  30. package/dist/shared/response-helpers.test.d.ts +2 -0
  31. package/dist/shared/response-helpers.test.d.ts.map +1 -0
  32. package/dist/shared/response-helpers.test.js +167 -0
  33. 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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=route-matcher.test.d.ts.map
@@ -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
+ });