@valkyrjaio/sindri 26.2.0 → 26.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.
@@ -13,6 +13,7 @@ import { DynamicRoute } from '@valkyrjaio/valkyrja/Http/Routing/Data/DynamicRout
13
13
  import { Parameter } from '@valkyrjaio/valkyrja/Http/Routing/Data/Parameter.ts';
14
14
  import { Regex } from '@valkyrjaio/valkyrja/Http/Routing/Constant/Regex.ts';
15
15
  import { Processor } from '@valkyrjaio/valkyrja/Http/Routing/Processor/Processor.ts';
16
+ import { RequestMethod, allRequestMethods } from '@valkyrjaio/valkyrja/Http/Message/Enum/RequestMethod.ts';
16
17
 
17
18
  import { AstFileGenerator } from '../../Abstract/AstFileGenerator.ts';
18
19
  import { GeneratorUnreachableException } from '../../Throwable/Exception/GeneratorUnreachableException.ts';
@@ -22,6 +23,16 @@ import type { HttpParameterData } from '../../../Ast/Data/HttpParameterData.ts';
22
23
  import type { HttpRouteData } from '../../../Ast/Data/HttpRouteData.ts';
23
24
  import type { ProcessorContract } from '@valkyrjaio/valkyrja/Http/Routing/Processor/Contract/ProcessorContract.ts';
24
25
 
26
+ /** Metadata extracted from an imperative `new Route(...)` / `new DynamicRoute(...)` expression. */
27
+ interface ImperativeRouteMeta {
28
+ readonly expr: ts.Expression;
29
+ readonly name: string;
30
+ readonly path: string;
31
+ readonly isDynamic: boolean;
32
+ readonly regex: string;
33
+ readonly methods: readonly string[];
34
+ }
35
+
25
36
  export class AstHttpDataFileGenerator extends AstFileGenerator implements HttpDataFileGeneratorContract {
26
37
  public classImportMap: Record<string, string> = {};
27
38
 
@@ -49,18 +60,58 @@ export class AstHttpDataFileGenerator extends AstFileGenerator implements HttpDa
49
60
  routes: Record<string, ts.Expression>,
50
61
  routeData: Record<string, HttpRouteData>,
51
62
  ): GenerateStatus {
52
- const userImports = Object.entries(this.classImportMap)
53
- .map(([name, specifier]) => `import { ${name} } from '${specifier}';`)
54
- .join('\n');
55
-
56
- const userImportsBlock = userImports ? `${userImports}\n` : '';
63
+ const userImportsBlock = this.buildUserImportsBlock(this.classImportMap);
57
64
 
58
65
  const routesContent = this.getRoutesAsContent(routes, routeData);
59
66
  const paths = this.printNestedObject(this.buildPaths(routeData));
60
67
  const dynamicPaths = this.printNestedObject(this.buildDynamicPaths(routeData));
61
68
  const regexes = this.printNestedObject(this.buildRegexes(routeData));
62
69
 
63
- const data = [
70
+ const data = this.assembleFile(className, userImportsBlock, routesContent, paths, dynamicPaths, regexes);
71
+
72
+ return this.writeFile(directory, className, data);
73
+ }
74
+
75
+ /**
76
+ * Generate the routing data file from imperative `getRoutes()` route
77
+ * objects (as opposed to attribute-scanned routes).
78
+ *
79
+ * Each route expression is emitted verbatim as a route closure and the
80
+ * path/dynamic-path/regex lookup maps are derived from the route objects'
81
+ * own arguments, mirroring the framework's runtime `RouteCollection`
82
+ * (literal path and regex, request methods defaulting to `[HEAD, GET]`).
83
+ */
84
+ public generateFileFromRoutes(
85
+ directory: string,
86
+ className: string,
87
+ _namespace: string,
88
+ routeExprs: readonly ts.Expression[],
89
+ ): GenerateStatus {
90
+ const userImportsBlock = this.buildUserImportsBlock(this.classImportMap);
91
+
92
+ const metas = routeExprs
93
+ .map((expr) => this.extractRouteMeta(expr))
94
+ .filter((meta): meta is ImperativeRouteMeta => meta !== undefined);
95
+
96
+ const routesContent = this.getImperativeRoutesContent(metas);
97
+ const paths = this.printNestedObject(this.buildImperativePaths(metas, false));
98
+ const dynamicPaths = this.printNestedObject(this.buildImperativePaths(metas, true));
99
+ const regexes = this.printNestedObject(this.buildImperativeRegexes(metas));
100
+
101
+ const data = this.assembleFile(className, userImportsBlock, routesContent, paths, dynamicPaths, regexes);
102
+
103
+ return this.writeFile(directory, className, data);
104
+ }
105
+
106
+ protected assembleFile(
107
+ className: string,
108
+ userImportsBlock: string,
109
+ routesContent: string,
110
+ paths: string,
111
+ dynamicPaths: string,
112
+ regexes: string,
113
+ ): string {
114
+ return [
64
115
  '// This file was automatically generated by Sindri.',
65
116
  '',
66
117
  `import { HttpRoutingData } from '@valkyrjaio/valkyrja/Http/Routing/Data/HttpRoutingData.ts';`,
@@ -83,8 +134,6 @@ export class AstHttpDataFileGenerator extends AstFileGenerator implements HttpDa
83
134
  `}`,
84
135
  '',
85
136
  ].join('\n');
86
-
87
- return this.writeFile(directory, className, data);
88
137
  }
89
138
 
90
139
  public generateClassContents(
@@ -292,15 +341,130 @@ export class AstHttpDataFileGenerator extends AstFileGenerator implements HttpDa
292
341
  expr = ts.factory.createNewExpression(expr.expression, expr.typeArguments, args);
293
342
  }
294
343
 
295
- const keyExpr = this.buildEnumCaseExpr(key);
296
- const printedKey = this.printer.printNode(ts.EmitHint.Unspecified, keyExpr, this.dummySourceFile);
297
344
  const printedRoute = this.printer.printNode(ts.EmitHint.Unspecified, expr, this.dummySourceFile);
298
-
299
- const formattedKey = key.includes('::') ? `[${printedKey}]` : `['${printedKey}']`;
345
+ const formattedKey = key.includes('::')
346
+ ? `[${this.printer.printNode(ts.EmitHint.Unspecified, this.buildEnumCaseExpr(key), this.dummySourceFile)}]`
347
+ : `['${key}']`;
300
348
 
301
349
  lines.push(` ${formattedKey}: (): RouteContract => ${printedRoute},`);
302
350
  }
303
351
 
304
352
  return ['{', ...lines, ' }'].join('\n ');
305
353
  }
354
+
355
+ // -------------------------------------------------------------------------
356
+ // Imperative `getRoutes()` support
357
+ // -------------------------------------------------------------------------
358
+
359
+ /**
360
+ * Extract routing metadata from a `new Route(...)` / `new DynamicRoute(...)`
361
+ * expression, matching the framework constructor argument order:
362
+ * Route(path, name, handler, requestMethods?)
363
+ * DynamicRoute(path, name, regex, parameters, handler, requestMethods?)
364
+ */
365
+ protected extractRouteMeta(expr: ts.Expression): ImperativeRouteMeta | undefined {
366
+ if (!ts.isNewExpression(expr) || !ts.isIdentifier(expr.expression)) {
367
+ return undefined;
368
+ }
369
+
370
+ const isDynamic = expr.expression.text === 'DynamicRoute';
371
+ const args = expr.arguments ?? ts.factory.createNodeArray<ts.Expression>();
372
+
373
+ const path = this.stringArg(args[0]);
374
+ const name = this.stringArg(args[1]);
375
+
376
+ if (name === undefined || path === undefined) {
377
+ return undefined;
378
+ }
379
+
380
+ const regex = isDynamic ? (this.stringArg(args[2]) ?? '') : '';
381
+ const methodsArg = isDynamic ? args[5] : args[3];
382
+ const methods = this.extractMethodNamesFromArg(methodsArg);
383
+
384
+ return { expr, name, path, isDynamic, regex, methods };
385
+ }
386
+
387
+ protected stringArg(node: ts.Expression | undefined): string | undefined {
388
+ return node !== undefined && ts.isStringLiteral(node) ? node.text : undefined;
389
+ }
390
+
391
+ /**
392
+ * Resolve a `[RequestMethod.GET, ...]` argument to method-name strings,
393
+ * defaulting to `[HEAD, GET]` (the framework `Route` default) when omitted
394
+ * and expanding `RequestMethod.ANY` to every request method.
395
+ */
396
+ protected extractMethodNamesFromArg(node: ts.Expression | undefined): string[] {
397
+ if (node === undefined || !ts.isArrayLiteralExpression(node)) {
398
+ return [RequestMethod.HEAD, RequestMethod.GET];
399
+ }
400
+
401
+ const methods: string[] = [];
402
+
403
+ for (const element of node.elements) {
404
+ if (!ts.isPropertyAccessExpression(element)) {
405
+ continue;
406
+ }
407
+
408
+ const method = element.name.text;
409
+
410
+ if (method === 'ANY') {
411
+ return allRequestMethods();
412
+ }
413
+
414
+ methods.push(method);
415
+ }
416
+
417
+ return methods;
418
+ }
419
+
420
+ protected getImperativeRoutesContent(metas: readonly ImperativeRouteMeta[]): string {
421
+ if (metas.length === 0) {
422
+ return '{}';
423
+ }
424
+
425
+ const lines: string[] = [];
426
+
427
+ for (const meta of metas) {
428
+ // Imperative route expressions come parsed from a provider's `getRoutes()`
429
+ // body, so they are emitted verbatim via their original source text.
430
+ lines.push(` ['${meta.name}']: (): RouteContract => ${meta.expr.getText()},`);
431
+ }
432
+
433
+ return ['{', ...lines, ' }'].join('\n ');
434
+ }
435
+
436
+ protected buildImperativePaths(
437
+ metas: readonly ImperativeRouteMeta[],
438
+ dynamic: boolean,
439
+ ): Record<string, Record<string, string>> {
440
+ const paths: Record<string, Record<string, string>> = {};
441
+
442
+ for (const meta of metas) {
443
+ if (meta.isDynamic !== dynamic) {
444
+ continue;
445
+ }
446
+
447
+ for (const method of meta.methods) {
448
+ (paths[method] ??= {})[meta.path] = meta.name;
449
+ }
450
+ }
451
+
452
+ return paths;
453
+ }
454
+
455
+ protected buildImperativeRegexes(metas: readonly ImperativeRouteMeta[]): Record<string, Record<string, string>> {
456
+ const regexes: Record<string, Record<string, string>> = {};
457
+
458
+ for (const meta of metas) {
459
+ if (!meta.isDynamic || meta.regex === '') {
460
+ continue;
461
+ }
462
+
463
+ for (const method of meta.methods) {
464
+ (regexes[method] ??= {})[meta.regex] = meta.name;
465
+ }
466
+ }
467
+
468
+ return regexes;
469
+ }
306
470
  }
@@ -12,6 +12,9 @@ import type { ts } from 'ts-morph';
12
12
  import type { GenerateStatus } from '../../Enum/GenerateStatus.ts';
13
13
 
14
14
  export interface CliDataFileGeneratorContract {
15
+ /** Map of class name → module specifier for the provider imports the data cache references. */
16
+ classImportMap: Record<string, string>;
17
+
15
18
  generateFile(
16
19
  directory: string,
17
20
  className: string,
@@ -19,5 +22,12 @@ export interface CliDataFileGeneratorContract {
19
22
  routes: Record<string, ts.Expression>,
20
23
  ): GenerateStatus;
21
24
 
25
+ generateFileFromRoutes(
26
+ directory: string,
27
+ className: string,
28
+ namespace: string,
29
+ routeExprs: readonly ts.Expression[],
30
+ ): GenerateStatus;
31
+
22
32
  generateClassContents(routes: Record<string, ts.Expression>): string;
23
33
  }
@@ -10,6 +10,9 @@
10
10
  import type { GenerateStatus } from '../../Enum/GenerateStatus.ts';
11
11
 
12
12
  export interface ContainerDataFileGeneratorContract {
13
+ /** Map of class name → module specifier for the provider imports the data cache references. */
14
+ classImportMap: Record<string, string>;
15
+
13
16
  generateFile(
14
17
  directory: string,
15
18
  className: string,
@@ -13,6 +13,9 @@ import type { HttpRouteData } from '../../../Ast/Data/HttpRouteData.ts';
13
13
  import type { GenerateStatus } from '../../Enum/GenerateStatus.ts';
14
14
 
15
15
  export interface HttpDataFileGeneratorContract {
16
+ /** Map of class name → module specifier for the provider imports the data cache references. */
17
+ classImportMap: Record<string, string>;
18
+
16
19
  generateFile(
17
20
  directory: string,
18
21
  className: string,
@@ -21,5 +24,12 @@ export interface HttpDataFileGeneratorContract {
21
24
  routeData: Record<string, HttpRouteData>,
22
25
  ): GenerateStatus;
23
26
 
27
+ generateFileFromRoutes(
28
+ directory: string,
29
+ className: string,
30
+ namespace: string,
31
+ routeExprs: readonly ts.Expression[],
32
+ ): GenerateStatus;
33
+
24
34
  generateClassContents(routes: Record<string, ts.Expression>, routeData: Record<string, HttpRouteData>): string;
25
35
  }