@hyperfixi/server-bridge 2.0.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/LICENSE +20 -0
- package/dist/cli/config.d.ts +5 -0
- package/dist/cli/diff-command.d.ts +9 -0
- package/dist/cli/generate-command.d.ts +12 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/scan-command.d.ts +9 -0
- package/dist/cli/watch-command.d.ts +30 -0
- package/dist/cli.cjs +1853 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/conventions/convention-engine.d.ts +29 -0
- package/dist/conventions/url-patterns.d.ts +37 -0
- package/dist/generate.d.ts +21 -0
- package/dist/generators/django-generator.d.ts +10 -0
- package/dist/generators/express-generator.d.ts +9 -0
- package/dist/generators/fastapi-generator.d.ts +10 -0
- package/dist/generators/hono-generator.d.ts +9 -0
- package/dist/generators/index.d.ts +6 -0
- package/dist/generators/manifest.d.ts +43 -0
- package/dist/generators/openapi-generator.d.ts +14 -0
- package/dist/generators/template-helpers.d.ts +24 -0
- package/dist/generators/types.d.ts +11 -0
- package/dist/index.cjs +1426 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +1376 -0
- package/dist/index.js.map +1 -0
- package/dist/scanner/conflict-detector.d.ts +10 -0
- package/dist/scanner/form-scanner.d.ts +12 -0
- package/dist/scanner/htmx-extractor.d.ts +9 -0
- package/dist/scanner/hyperscript-extractor.d.ts +9 -0
- package/dist/scanner/route-scanner.d.ts +21 -0
- package/dist/types.d.ts +123 -0
- package/package.json +64 -0
- package/src/__tests__/cli.test.ts +30 -0
- package/src/__tests__/conflict-detector.test.ts +126 -0
- package/src/__tests__/convention-engine.test.ts +58 -0
- package/src/__tests__/diff-command.test.ts +69 -0
- package/src/__tests__/django-generator.test.ts +129 -0
- package/src/__tests__/express-generator.test.ts +105 -0
- package/src/__tests__/fastapi-generator.test.ts +125 -0
- package/src/__tests__/form-scanner.test.ts +91 -0
- package/src/__tests__/generate.test.ts +89 -0
- package/src/__tests__/hono-generator.test.ts +90 -0
- package/src/__tests__/htmx-extractor.test.ts +84 -0
- package/src/__tests__/hyperscript-extractor.test.ts +89 -0
- package/src/__tests__/integration.test.ts +102 -0
- package/src/__tests__/manifest.test.ts +133 -0
- package/src/__tests__/openapi-generator.test.ts +116 -0
- package/src/__tests__/query-params.test.ts +101 -0
- package/src/__tests__/route-scanner.test.ts +47 -0
- package/src/__tests__/url-patterns.test.ts +74 -0
- package/src/__tests__/watch-command.test.ts +141 -0
- package/src/cli/config.ts +35 -0
- package/src/cli/diff-command.ts +96 -0
- package/src/cli/generate-command.ts +108 -0
- package/src/cli/index.ts +143 -0
- package/src/cli/scan-command.ts +72 -0
- package/src/cli/watch-command.ts +242 -0
- package/src/conventions/convention-engine.ts +68 -0
- package/src/conventions/url-patterns.ts +123 -0
- package/src/generate.ts +44 -0
- package/src/generators/django-generator.ts +210 -0
- package/src/generators/express-generator.ts +137 -0
- package/src/generators/fastapi-generator.ts +248 -0
- package/src/generators/hono-generator.ts +137 -0
- package/src/generators/index.ts +25 -0
- package/src/generators/manifest.ts +164 -0
- package/src/generators/openapi-generator.ts +259 -0
- package/src/generators/template-helpers.ts +77 -0
- package/src/generators/types.ts +12 -0
- package/src/index.ts +42 -0
- package/src/scanner/conflict-detector.ts +92 -0
- package/src/scanner/form-scanner.ts +126 -0
- package/src/scanner/htmx-extractor.ts +174 -0
- package/src/scanner/hyperscript-extractor.ts +184 -0
- package/src/scanner/route-scanner.ts +130 -0
- package/src/types.ts +151 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { HttpMethod, QueryParam } from '../types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Extract path parameter names from a URL pattern.
|
|
5
|
+
* Handles both `:param` and `{param}` styles.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* extractPathParams('/api/users/:id') // ['id']
|
|
9
|
+
* extractPathParams('/api/users/{id}/orders/{orderId}') // ['id', 'orderId']
|
|
10
|
+
*/
|
|
11
|
+
export function extractPathParams(url: string): string[] {
|
|
12
|
+
const params: string[] = [];
|
|
13
|
+
// Match :param style
|
|
14
|
+
for (const match of url.matchAll(/:(\w+)/g)) {
|
|
15
|
+
params.push(match[1]);
|
|
16
|
+
}
|
|
17
|
+
// Match {param} style
|
|
18
|
+
for (const match of url.matchAll(/\{(\w+)\}/g)) {
|
|
19
|
+
if (!params.includes(match[1])) {
|
|
20
|
+
params.push(match[1]);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return params;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Normalize a URL path:
|
|
28
|
+
* - Convert `{param}` to `:param`
|
|
29
|
+
* - Strip trailing slashes
|
|
30
|
+
* - Ensure leading slash
|
|
31
|
+
* - Strip query strings
|
|
32
|
+
* - Strip fragments
|
|
33
|
+
*/
|
|
34
|
+
export function normalizeUrl(url: string): string {
|
|
35
|
+
// Strip query string and fragment
|
|
36
|
+
let normalized = url.split('?')[0].split('#')[0];
|
|
37
|
+
|
|
38
|
+
// Convert {param} to :param
|
|
39
|
+
normalized = normalized.replace(/\{(\w+)\}/g, ':$1');
|
|
40
|
+
|
|
41
|
+
// Ensure leading slash
|
|
42
|
+
if (!normalized.startsWith('/')) {
|
|
43
|
+
normalized = '/' + normalized;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Strip trailing slash (but keep root /)
|
|
47
|
+
if (normalized.length > 1 && normalized.endsWith('/')) {
|
|
48
|
+
normalized = normalized.slice(0, -1);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return normalized;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Convert a URL path + method into a handler function name.
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* urlToHandlerName('/api/users', 'GET') // 'getApiUsers'
|
|
59
|
+
* urlToHandlerName('/api/users/:id', 'POST') // 'postApiUsersById'
|
|
60
|
+
*/
|
|
61
|
+
export function urlToHandlerName(url: string, method: HttpMethod): string {
|
|
62
|
+
const prefix = method.toLowerCase();
|
|
63
|
+
|
|
64
|
+
// Remove leading slash, split by / and :
|
|
65
|
+
const segments = url
|
|
66
|
+
.replace(/^\//, '')
|
|
67
|
+
.split('/')
|
|
68
|
+
.map(segment => {
|
|
69
|
+
if (segment.startsWith(':')) {
|
|
70
|
+
// :id -> ById, :userId -> ByUserId
|
|
71
|
+
const param = segment.slice(1);
|
|
72
|
+
return 'By' + capitalize(param);
|
|
73
|
+
}
|
|
74
|
+
// api -> Api, user-list -> UserList
|
|
75
|
+
return segment.split(/[-_]/).map(capitalize).join('');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
return prefix + segments.join('');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Extract query parameter names from a URL string.
|
|
83
|
+
* Must be called BEFORE normalizeUrl (which strips query strings).
|
|
84
|
+
*
|
|
85
|
+
* @example
|
|
86
|
+
* extractQueryParams('/api/search?q=hello&limit=10')
|
|
87
|
+
* // [{ name: 'q', type: 'string', required: true },
|
|
88
|
+
* // { name: 'limit', type: 'string', required: true }]
|
|
89
|
+
*/
|
|
90
|
+
export function extractQueryParams(url: string): QueryParam[] {
|
|
91
|
+
const queryIndex = url.indexOf('?');
|
|
92
|
+
if (queryIndex === -1) return [];
|
|
93
|
+
|
|
94
|
+
const queryString = url.slice(queryIndex + 1).split('#')[0];
|
|
95
|
+
if (!queryString) return [];
|
|
96
|
+
|
|
97
|
+
const params: QueryParam[] = [];
|
|
98
|
+
const seen = new Set<string>();
|
|
99
|
+
|
|
100
|
+
for (const pair of queryString.split('&')) {
|
|
101
|
+
const eqIndex = pair.indexOf('=');
|
|
102
|
+
const rawName = eqIndex === -1 ? pair : pair.slice(0, eqIndex);
|
|
103
|
+
if (!rawName) continue;
|
|
104
|
+
|
|
105
|
+
let name: string;
|
|
106
|
+
try {
|
|
107
|
+
name = decodeURIComponent(rawName);
|
|
108
|
+
} catch {
|
|
109
|
+
name = rawName;
|
|
110
|
+
}
|
|
111
|
+
if (seen.has(name)) continue;
|
|
112
|
+
seen.add(name);
|
|
113
|
+
|
|
114
|
+
params.push({ name, type: 'string', required: true });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return params;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function capitalize(s: string): string {
|
|
121
|
+
if (!s) return '';
|
|
122
|
+
return s[0].toUpperCase() + s.slice(1);
|
|
123
|
+
}
|
package/src/generate.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { scanDirectory } from './scanner/route-scanner.js';
|
|
3
|
+
import { selectGenerator } from './generators/index.js';
|
|
4
|
+
import type { GenerateResult, RouteDescriptor, ScanResult } from './types.js';
|
|
5
|
+
|
|
6
|
+
export interface ProgrammaticGenerateOptions {
|
|
7
|
+
dir: string;
|
|
8
|
+
framework?: 'express' | 'hono' | 'openapi' | 'django' | 'fastapi';
|
|
9
|
+
output?: string;
|
|
10
|
+
typescript?: boolean;
|
|
11
|
+
include?: string[];
|
|
12
|
+
exclude?: string[];
|
|
13
|
+
ignore?: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface GenerateFullResult {
|
|
17
|
+
scan: ScanResult;
|
|
18
|
+
generated: GenerateResult;
|
|
19
|
+
routes: RouteDescriptor[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Programmatic scan + generate. Scans a directory for routes and generates
|
|
24
|
+
* framework-specific route files. Returns structured results for the caller
|
|
25
|
+
* to write to disk.
|
|
26
|
+
*/
|
|
27
|
+
export async function generate(options: ProgrammaticGenerateOptions): Promise<GenerateFullResult> {
|
|
28
|
+
const cwd = resolve(options.dir);
|
|
29
|
+
const outputDir = resolve(cwd, options.output ?? './server/routes');
|
|
30
|
+
|
|
31
|
+
const scan = await scanDirectory(cwd, {
|
|
32
|
+
include: options.include,
|
|
33
|
+
exclude: options.exclude,
|
|
34
|
+
ignore: options.ignore,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const generator = selectGenerator(options.framework ?? 'express');
|
|
38
|
+
const generated = generator.generate(scan.routes, {
|
|
39
|
+
outputDir,
|
|
40
|
+
typescript: options.typescript,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
return { scan, generated, routes: scan.routes };
|
|
44
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import type { RouteDescriptor, GeneratorOptions, GenerateResult, GeneratedFile } from '../types.js';
|
|
2
|
+
import type { RouteGenerator } from './types.js';
|
|
3
|
+
import { groupRoutesByPrefix } from './template-helpers.js';
|
|
4
|
+
|
|
5
|
+
const PY_ROUTE_MARKER = '# @serverbridge-route:';
|
|
6
|
+
const PY_USER_START = '# @serverbridge-user-start';
|
|
7
|
+
const PY_USER_END = '# @serverbridge-user-end';
|
|
8
|
+
|
|
9
|
+
export class DjangoGenerator implements RouteGenerator {
|
|
10
|
+
readonly framework = 'django';
|
|
11
|
+
|
|
12
|
+
generate(routes: RouteDescriptor[], options: GeneratorOptions): GenerateResult {
|
|
13
|
+
const files: GeneratedFile[] = [];
|
|
14
|
+
const warnings: string[] = [];
|
|
15
|
+
|
|
16
|
+
if (routes.length === 0) {
|
|
17
|
+
warnings.push('No routes to generate');
|
|
18
|
+
return { files, preserved: [], warnings };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const groups = groupRoutesByPrefix(routes);
|
|
22
|
+
|
|
23
|
+
// Generate views.py and urls.py per group
|
|
24
|
+
for (const [groupKey, groupRoutes] of groups) {
|
|
25
|
+
const views = this.generateViews(groupRoutes, groupKey);
|
|
26
|
+
files.push({ path: `${groupKey}/views.py`, content: views, isNew: true });
|
|
27
|
+
|
|
28
|
+
const urls = this.generateUrls(groupRoutes, groupKey);
|
|
29
|
+
files.push({ path: `${groupKey}/urls.py`, content: urls, isNew: true });
|
|
30
|
+
|
|
31
|
+
// Empty __init__.py for Python package
|
|
32
|
+
files.push({ path: `${groupKey}/__init__.py`, content: '', isNew: true });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Root urls.py that includes all groups
|
|
36
|
+
const rootUrls = this.generateRootUrls(groups);
|
|
37
|
+
files.push({ path: 'urls.py', content: rootUrls, isNew: true });
|
|
38
|
+
|
|
39
|
+
return { files, preserved: [], warnings };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private generateViews(routes: RouteDescriptor[], groupKey: string): string {
|
|
43
|
+
const lines: string[] = [];
|
|
44
|
+
|
|
45
|
+
lines.push(`# Generated by @hyperfixi/server-bridge — DO NOT EDIT scaffold markers`);
|
|
46
|
+
lines.push(`from django.http import JsonResponse, HttpResponse`);
|
|
47
|
+
|
|
48
|
+
// Check if any route needs request body parsing
|
|
49
|
+
const needsJson = routes.some(
|
|
50
|
+
r => r.requestBody && r.requestBody.length > 0 && ['POST', 'PUT', 'PATCH'].includes(r.method)
|
|
51
|
+
);
|
|
52
|
+
if (needsJson) {
|
|
53
|
+
lines.push(`import json`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Check if we need decorators
|
|
57
|
+
const hasNonGet = routes.some(r => r.method !== 'GET');
|
|
58
|
+
if (hasNonGet) {
|
|
59
|
+
lines.push(`from django.views.decorators.http import require_http_methods`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
lines.push('');
|
|
63
|
+
|
|
64
|
+
for (const route of routes) {
|
|
65
|
+
lines.push('');
|
|
66
|
+
lines.push(`${PY_ROUTE_MARKER} ${route.method} ${route.path}`);
|
|
67
|
+
lines.push(this.generateView(route));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
lines.push('');
|
|
71
|
+
return lines.join('\n');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private generateView(route: RouteDescriptor): string {
|
|
75
|
+
const lines: string[] = [];
|
|
76
|
+
const funcName = toSnakeCase(route.handlerName);
|
|
77
|
+
|
|
78
|
+
// Method decorator for non-GET routes
|
|
79
|
+
if (route.method !== 'GET') {
|
|
80
|
+
lines.push(`@require_http_methods(["${route.method}"])`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Function signature with path params
|
|
84
|
+
const params = ['request', ...route.pathParams];
|
|
85
|
+
lines.push(`def ${funcName}(${params.join(', ')}):`);
|
|
86
|
+
lines.push(` ${PY_USER_START}`);
|
|
87
|
+
|
|
88
|
+
// TODO block
|
|
89
|
+
lines.push(` # TODO: Implement ${route.method} ${route.path}`);
|
|
90
|
+
if (route.pathParams.length > 0) {
|
|
91
|
+
lines.push(` # Path params: ${route.pathParams.join(', ')}`);
|
|
92
|
+
}
|
|
93
|
+
if (route.requestBody && route.requestBody.length > 0) {
|
|
94
|
+
const fields = route.requestBody
|
|
95
|
+
.map(f => `${f.name}: ${pythonType(f.type)}${f.required ? '' : ' (optional)'}`)
|
|
96
|
+
.join(', ');
|
|
97
|
+
lines.push(` # Expected body: { ${fields} }`);
|
|
98
|
+
}
|
|
99
|
+
for (const note of route.notes) {
|
|
100
|
+
lines.push(` # Note: ${note}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Extract query params
|
|
104
|
+
if (route.queryParams && route.queryParams.length > 0) {
|
|
105
|
+
for (const param of route.queryParams) {
|
|
106
|
+
lines.push(` ${param.name} = request.GET.get('${param.name}')`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Parse body for POST/PUT/PATCH
|
|
111
|
+
if (
|
|
112
|
+
route.requestBody &&
|
|
113
|
+
route.requestBody.length > 0 &&
|
|
114
|
+
['POST', 'PUT', 'PATCH'].includes(route.method)
|
|
115
|
+
) {
|
|
116
|
+
lines.push(` data = json.loads(request.body)`);
|
|
117
|
+
for (const field of route.requestBody) {
|
|
118
|
+
const default_ = field.required ? '' : ', None';
|
|
119
|
+
lines.push(` ${field.name} = data.get('${field.name}'${default_})`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Response stub
|
|
124
|
+
if (route.responseFormat === 'json') {
|
|
125
|
+
lines.push(` return JsonResponse({'message': 'Not implemented'})`);
|
|
126
|
+
} else if (route.responseFormat === 'html') {
|
|
127
|
+
lines.push(` return HttpResponse('<div>Not implemented</div>')`);
|
|
128
|
+
} else {
|
|
129
|
+
lines.push(` return HttpResponse('Not implemented', content_type='text/plain')`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
lines.push(` ${PY_USER_END}`);
|
|
133
|
+
return lines.join('\n');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private generateUrls(routes: RouteDescriptor[], groupKey: string): string {
|
|
137
|
+
const lines: string[] = [];
|
|
138
|
+
|
|
139
|
+
lines.push(`# Generated by @hyperfixi/server-bridge — DO NOT EDIT`);
|
|
140
|
+
lines.push(`from django.urls import path`);
|
|
141
|
+
lines.push(`from . import views`);
|
|
142
|
+
lines.push('');
|
|
143
|
+
lines.push('urlpatterns = [');
|
|
144
|
+
|
|
145
|
+
for (const route of routes) {
|
|
146
|
+
const djangoPath = toDjangoPath(route.path);
|
|
147
|
+
const funcName = toSnakeCase(route.handlerName);
|
|
148
|
+
lines.push(` path('${djangoPath}', views.${funcName}, name='${funcName}'),`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
lines.push(']');
|
|
152
|
+
lines.push('');
|
|
153
|
+
|
|
154
|
+
return lines.join('\n');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private generateRootUrls(groups: Map<string, RouteDescriptor[]>): string {
|
|
158
|
+
const lines: string[] = [];
|
|
159
|
+
|
|
160
|
+
lines.push(`# Generated by @hyperfixi/server-bridge — DO NOT EDIT`);
|
|
161
|
+
lines.push(`from django.urls import path, include`);
|
|
162
|
+
lines.push('');
|
|
163
|
+
lines.push('urlpatterns = [');
|
|
164
|
+
|
|
165
|
+
for (const groupKey of groups.keys()) {
|
|
166
|
+
lines.push(` path('', include('${groupKey}.urls')),`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
lines.push(']');
|
|
170
|
+
lines.push('');
|
|
171
|
+
|
|
172
|
+
return lines.join('\n');
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Convert camelCase handler name to snake_case for Python.
|
|
178
|
+
*/
|
|
179
|
+
function toSnakeCase(str: string): string {
|
|
180
|
+
return str
|
|
181
|
+
.replace(/([A-Z])/g, '_$1')
|
|
182
|
+
.toLowerCase()
|
|
183
|
+
.replace(/^_/, '');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Convert Express-style :param path to Django <param> path.
|
|
188
|
+
* "/api/users/:id" → "api/users/<int:id>"
|
|
189
|
+
*/
|
|
190
|
+
function toDjangoPath(path: string): string {
|
|
191
|
+
return path
|
|
192
|
+
.replace(/^\//, '') // Strip leading slash (Django convention)
|
|
193
|
+
.replace(/:(\w+)/g, '<str:$1>'); // :param → <str:param>
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Map our field types to Python type hints.
|
|
198
|
+
*/
|
|
199
|
+
function pythonType(type: string): string {
|
|
200
|
+
switch (type) {
|
|
201
|
+
case 'number':
|
|
202
|
+
return 'int';
|
|
203
|
+
case 'boolean':
|
|
204
|
+
return 'bool';
|
|
205
|
+
case 'file':
|
|
206
|
+
return 'UploadedFile';
|
|
207
|
+
default:
|
|
208
|
+
return 'str';
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { RouteDescriptor, GeneratorOptions, GenerateResult, GeneratedFile } from '../types.js';
|
|
2
|
+
import type { RouteGenerator } from './types.js';
|
|
3
|
+
import {
|
|
4
|
+
routeMarker,
|
|
5
|
+
todoBlock,
|
|
6
|
+
USER_START,
|
|
7
|
+
USER_END,
|
|
8
|
+
groupRoutesByPrefix,
|
|
9
|
+
groupToFilename,
|
|
10
|
+
} from './template-helpers.js';
|
|
11
|
+
|
|
12
|
+
export class ExpressGenerator implements RouteGenerator {
|
|
13
|
+
readonly framework = 'express';
|
|
14
|
+
|
|
15
|
+
generate(routes: RouteDescriptor[], options: GeneratorOptions): GenerateResult {
|
|
16
|
+
const ts = options.typescript !== false; // default true
|
|
17
|
+
const files: GeneratedFile[] = [];
|
|
18
|
+
const warnings: string[] = [];
|
|
19
|
+
|
|
20
|
+
if (routes.length === 0) {
|
|
21
|
+
warnings.push('No routes to generate');
|
|
22
|
+
return { files, preserved: [], warnings };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const groups = groupRoutesByPrefix(routes);
|
|
26
|
+
|
|
27
|
+
// Generate one router file per group
|
|
28
|
+
for (const [groupKey, groupRoutes] of groups) {
|
|
29
|
+
const filename = groupToFilename(groupKey, ts);
|
|
30
|
+
const content = this.generateRouterFile(groupRoutes, groupKey, ts);
|
|
31
|
+
files.push({ path: `routes/${filename}`, content, isNew: true });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Generate index file that imports all routers
|
|
35
|
+
const indexContent = this.generateIndex(groups, ts);
|
|
36
|
+
files.push({ path: `routes/index.${ts ? 'ts' : 'js'}`, content: indexContent, isNew: true });
|
|
37
|
+
|
|
38
|
+
return { files, preserved: [], warnings };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private generateRouterFile(routes: RouteDescriptor[], groupKey: string, ts: boolean): string {
|
|
42
|
+
const lines: string[] = [];
|
|
43
|
+
|
|
44
|
+
lines.push(`// Generated by @hyperfixi/server-bridge — DO NOT EDIT scaffold markers`);
|
|
45
|
+
lines.push(`import { Router${ts ? ', Request, Response' : ''} } from 'express';`);
|
|
46
|
+
lines.push('');
|
|
47
|
+
lines.push('const router = Router();');
|
|
48
|
+
lines.push('');
|
|
49
|
+
|
|
50
|
+
for (const route of routes) {
|
|
51
|
+
lines.push(routeMarker(route));
|
|
52
|
+
lines.push(this.generateHandler(route, ts));
|
|
53
|
+
lines.push('');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
lines.push('export default router;');
|
|
57
|
+
lines.push('');
|
|
58
|
+
|
|
59
|
+
return lines.join('\n');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private generateHandler(route: RouteDescriptor, ts: boolean): string {
|
|
63
|
+
const method = route.method.toLowerCase();
|
|
64
|
+
const params = ts ? '(req: Request, res: Response)' : '(req, res)';
|
|
65
|
+
const lines: string[] = [];
|
|
66
|
+
|
|
67
|
+
lines.push(`router.${method}('${route.path}', async ${params} => {`);
|
|
68
|
+
lines.push(` ${USER_START}`);
|
|
69
|
+
lines.push(todoBlock(route, ' '));
|
|
70
|
+
|
|
71
|
+
// Destructure path params
|
|
72
|
+
if (route.pathParams.length > 0) {
|
|
73
|
+
const paramList = route.pathParams.join(', ');
|
|
74
|
+
lines.push(` const { ${paramList} } = req.params;`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Destructure query params
|
|
78
|
+
if (route.queryParams && route.queryParams.length > 0) {
|
|
79
|
+
const paramList = route.queryParams.map(p => p.name).join(', ');
|
|
80
|
+
lines.push(` const { ${paramList} } = req.query;`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Destructure body fields for POST/PUT/PATCH
|
|
84
|
+
if (
|
|
85
|
+
route.requestBody &&
|
|
86
|
+
route.requestBody.length > 0 &&
|
|
87
|
+
['POST', 'PUT', 'PATCH'].includes(route.method)
|
|
88
|
+
) {
|
|
89
|
+
const fieldList = route.requestBody.map(f => f.name).join(', ');
|
|
90
|
+
lines.push(` const { ${fieldList} } = req.body;`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Response stub
|
|
94
|
+
if (route.responseFormat === 'json') {
|
|
95
|
+
lines.push(` res.json({ message: 'Not implemented' });`);
|
|
96
|
+
} else if (route.responseFormat === 'html') {
|
|
97
|
+
lines.push(` res.send('<div>Not implemented</div>');`);
|
|
98
|
+
} else {
|
|
99
|
+
lines.push(` res.send('Not implemented');`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
lines.push(` ${USER_END}`);
|
|
103
|
+
lines.push(`});`);
|
|
104
|
+
|
|
105
|
+
return lines.join('\n');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private generateIndex(groups: Map<string, RouteDescriptor[]>, ts: boolean): string {
|
|
109
|
+
const lines: string[] = [];
|
|
110
|
+
|
|
111
|
+
lines.push(`// Generated by @hyperfixi/server-bridge — DO NOT EDIT`);
|
|
112
|
+
lines.push(`import { Router } from 'express';`);
|
|
113
|
+
|
|
114
|
+
for (const groupKey of groups.keys()) {
|
|
115
|
+
const filename = groupKey;
|
|
116
|
+
lines.push(`import ${toCamelCase(groupKey)}Router from './${filename}.js';`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
lines.push('');
|
|
120
|
+
lines.push('const router = Router();');
|
|
121
|
+
lines.push('');
|
|
122
|
+
|
|
123
|
+
for (const groupKey of groups.keys()) {
|
|
124
|
+
lines.push(`router.use(${toCamelCase(groupKey)}Router);`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
lines.push('');
|
|
128
|
+
lines.push('export default router;');
|
|
129
|
+
lines.push('');
|
|
130
|
+
|
|
131
|
+
return lines.join('\n');
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function toCamelCase(str: string): string {
|
|
136
|
+
return str.replace(/-(\w)/g, (_, c) => c.toUpperCase());
|
|
137
|
+
}
|