@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,126 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { detectConflicts, formatConflicts } from '../scanner/conflict-detector.js';
|
|
3
|
+
import type { RouteDescriptor } from '../types.js';
|
|
4
|
+
|
|
5
|
+
function makeRoute(overrides: Partial<RouteDescriptor>): RouteDescriptor {
|
|
6
|
+
return {
|
|
7
|
+
path: '/api/users',
|
|
8
|
+
method: 'GET',
|
|
9
|
+
responseFormat: 'json',
|
|
10
|
+
source: { file: 'test.html', line: 1, kind: 'fetch', raw: 'fetch /api/users' },
|
|
11
|
+
pathParams: [],
|
|
12
|
+
handlerName: 'getApiUsers',
|
|
13
|
+
notes: [],
|
|
14
|
+
...overrides,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('detectConflicts', () => {
|
|
19
|
+
it('returns empty array when no conflicts', () => {
|
|
20
|
+
const routes = [
|
|
21
|
+
makeRoute({ path: '/api/users', method: 'GET' }),
|
|
22
|
+
makeRoute({ path: '/api/posts', method: 'GET' }),
|
|
23
|
+
];
|
|
24
|
+
expect(detectConflicts(routes)).toEqual([]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('returns empty array for single-occurrence routes', () => {
|
|
28
|
+
const routes = [makeRoute({})];
|
|
29
|
+
expect(detectConflicts(routes)).toEqual([]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('returns empty array when duplicates have same expectations', () => {
|
|
33
|
+
const routes = [
|
|
34
|
+
makeRoute({
|
|
35
|
+
responseFormat: 'json',
|
|
36
|
+
source: { file: 'a.html', line: 1, kind: 'fetch', raw: '' },
|
|
37
|
+
}),
|
|
38
|
+
makeRoute({
|
|
39
|
+
responseFormat: 'json',
|
|
40
|
+
source: { file: 'b.html', line: 2, kind: 'hx-attr', raw: '' },
|
|
41
|
+
}),
|
|
42
|
+
];
|
|
43
|
+
expect(detectConflicts(routes)).toEqual([]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('detects responseFormat conflict', () => {
|
|
47
|
+
const routes = [
|
|
48
|
+
makeRoute({
|
|
49
|
+
responseFormat: 'json',
|
|
50
|
+
source: { file: 'a.html', line: 1, kind: 'fetch', raw: '' },
|
|
51
|
+
}),
|
|
52
|
+
makeRoute({
|
|
53
|
+
responseFormat: 'html',
|
|
54
|
+
source: { file: 'b.html', line: 2, kind: 'hx-attr', raw: '' },
|
|
55
|
+
}),
|
|
56
|
+
];
|
|
57
|
+
const conflicts = detectConflicts(routes);
|
|
58
|
+
expect(conflicts).toHaveLength(1);
|
|
59
|
+
expect(conflicts[0].routeKey).toBe('GET:/api/users');
|
|
60
|
+
expect(conflicts[0].conflicts[0].field).toBe('responseFormat');
|
|
61
|
+
expect(conflicts[0].sources).toHaveLength(2);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('detects requestBody conflict on POST routes', () => {
|
|
65
|
+
const routes = [
|
|
66
|
+
makeRoute({
|
|
67
|
+
method: 'POST',
|
|
68
|
+
requestBody: [{ name: 'name', type: 'string', required: true }],
|
|
69
|
+
source: { file: 'a.html', line: 1, kind: 'fetch', raw: '' },
|
|
70
|
+
}),
|
|
71
|
+
makeRoute({
|
|
72
|
+
method: 'POST',
|
|
73
|
+
requestBody: [{ name: 'email', type: 'string', required: true }],
|
|
74
|
+
source: { file: 'b.html', line: 2, kind: 'hx-attr', raw: '' },
|
|
75
|
+
}),
|
|
76
|
+
];
|
|
77
|
+
const conflicts = detectConflicts(routes);
|
|
78
|
+
expect(conflicts).toHaveLength(1);
|
|
79
|
+
expect(conflicts[0].conflicts[0].field).toBe('requestBody');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('detects queryParams conflict', () => {
|
|
83
|
+
const routes = [
|
|
84
|
+
makeRoute({
|
|
85
|
+
queryParams: [{ name: 'q', type: 'string', required: true }],
|
|
86
|
+
source: { file: 'a.html', line: 1, kind: 'fetch', raw: '' },
|
|
87
|
+
}),
|
|
88
|
+
makeRoute({
|
|
89
|
+
queryParams: [{ name: 'search', type: 'string', required: true }],
|
|
90
|
+
source: { file: 'b.html', line: 2, kind: 'hx-attr', raw: '' },
|
|
91
|
+
}),
|
|
92
|
+
];
|
|
93
|
+
const conflicts = detectConflicts(routes);
|
|
94
|
+
expect(conflicts).toHaveLength(1);
|
|
95
|
+
expect(conflicts[0].conflicts[0].field).toBe('queryParams');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('ignores requestBody conflicts on GET routes', () => {
|
|
99
|
+
const routes = [
|
|
100
|
+
makeRoute({ method: 'GET', source: { file: 'a.html', line: 1, kind: 'fetch', raw: '' } }),
|
|
101
|
+
makeRoute({ method: 'GET', source: { file: 'b.html', line: 2, kind: 'hx-attr', raw: '' } }),
|
|
102
|
+
];
|
|
103
|
+
expect(detectConflicts(routes)).toEqual([]);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('formatConflicts', () => {
|
|
108
|
+
it('formats conflict warnings for CLI output', () => {
|
|
109
|
+
const routes = [
|
|
110
|
+
makeRoute({
|
|
111
|
+
responseFormat: 'json',
|
|
112
|
+
source: { file: 'a.html', line: 1, kind: 'fetch', raw: '' },
|
|
113
|
+
}),
|
|
114
|
+
makeRoute({
|
|
115
|
+
responseFormat: 'html',
|
|
116
|
+
source: { file: 'b.html', line: 2, kind: 'hx-attr', raw: '' },
|
|
117
|
+
}),
|
|
118
|
+
];
|
|
119
|
+
const conflicts = detectConflicts(routes);
|
|
120
|
+
const output = formatConflicts(conflicts);
|
|
121
|
+
expect(output).toContain('GET:/api/users');
|
|
122
|
+
expect(output).toContain('json vs html');
|
|
123
|
+
expect(output).toContain('a.html:1');
|
|
124
|
+
expect(output).toContain('b.html:2');
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { inferConventions } from '../conventions/convention-engine.js';
|
|
3
|
+
|
|
4
|
+
describe('inferConventions', () => {
|
|
5
|
+
it('defaults to GET + json', () => {
|
|
6
|
+
const result = inferConventions('/api/users');
|
|
7
|
+
expect(result.method).toBe('GET');
|
|
8
|
+
expect(result.responseFormat).toBe('json');
|
|
9
|
+
expect(result.handlerName).toBe('getApiUsers');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('uses explicit method from hx-post', () => {
|
|
13
|
+
const result = inferConventions('/api/users', { explicitMethod: 'POST' });
|
|
14
|
+
expect(result.method).toBe('POST');
|
|
15
|
+
expect(result.handlerName).toBe('postApiUsers');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('infers POST from form context', () => {
|
|
19
|
+
const result = inferConventions('/api/users', { elementTag: 'form' });
|
|
20
|
+
expect(result.method).toBe('POST');
|
|
21
|
+
expect(result.notes).toContain('Inferred POST from <form> context');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('explicit method overrides form context', () => {
|
|
25
|
+
const result = inferConventions('/api/users', {
|
|
26
|
+
elementTag: 'form',
|
|
27
|
+
explicitMethod: 'PUT',
|
|
28
|
+
});
|
|
29
|
+
expect(result.method).toBe('PUT');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('uses explicit response type from hyperscript', () => {
|
|
33
|
+
const result = inferConventions('/api/users', { fetchResponseType: 'html' });
|
|
34
|
+
expect(result.responseFormat).toBe('html');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('infers html from URL pattern', () => {
|
|
38
|
+
const result = inferConventions('/pages/dashboard');
|
|
39
|
+
expect(result.responseFormat).toBe('html');
|
|
40
|
+
expect(result.notes).toContain('Inferred html response from URL pattern');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('infers html from partials URL', () => {
|
|
44
|
+
const result = inferConventions('/partials/user-card');
|
|
45
|
+
expect(result.responseFormat).toBe('html');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('handles unknown response type', () => {
|
|
49
|
+
const result = inferConventions('/api/data', { fetchResponseType: 'blob' });
|
|
50
|
+
expect(result.responseFormat).toBe('json');
|
|
51
|
+
expect(result.notes[0]).toMatch(/Unknown response type/);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('generates correct handler name with params', () => {
|
|
55
|
+
const result = inferConventions('/api/users/:id', { explicitMethod: 'DELETE' });
|
|
56
|
+
expect(result.handlerName).toBe('deleteApiUsersById');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { diffRoutes, createManifest } from '../generators/manifest.js';
|
|
3
|
+
import type { RouteDescriptor } from '../types.js';
|
|
4
|
+
|
|
5
|
+
function makeRoute(overrides: Partial<RouteDescriptor>): RouteDescriptor {
|
|
6
|
+
return {
|
|
7
|
+
path: '/api/users',
|
|
8
|
+
method: 'GET',
|
|
9
|
+
responseFormat: 'json',
|
|
10
|
+
source: { file: 'test.html', line: 1, kind: 'fetch', raw: '' },
|
|
11
|
+
pathParams: [],
|
|
12
|
+
handlerName: 'getApiUsers',
|
|
13
|
+
notes: [],
|
|
14
|
+
...overrides,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('diff command logic', () => {
|
|
19
|
+
it('detects added routes against empty manifest', () => {
|
|
20
|
+
const manifest = { version: 1, generatedAt: '2024-01-01', routes: {} };
|
|
21
|
+
const routes = [makeRoute({})];
|
|
22
|
+
const diff = diffRoutes(manifest, routes);
|
|
23
|
+
expect(diff.added).toHaveLength(1);
|
|
24
|
+
expect(diff.removed).toHaveLength(0);
|
|
25
|
+
expect(diff.changed).toHaveLength(0);
|
|
26
|
+
expect(diff.unchanged).toHaveLength(0);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('detects removed routes', () => {
|
|
30
|
+
const routes = [makeRoute({})];
|
|
31
|
+
const manifest = createManifest(routes);
|
|
32
|
+
const diff = diffRoutes(manifest, []);
|
|
33
|
+
expect(diff.removed).toHaveLength(1);
|
|
34
|
+
expect(diff.added).toHaveLength(0);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('detects changed routes (format changed)', () => {
|
|
38
|
+
const original = [makeRoute({ responseFormat: 'json' })];
|
|
39
|
+
const manifest = createManifest(original);
|
|
40
|
+
const updated = [makeRoute({ responseFormat: 'html' })];
|
|
41
|
+
const diff = diffRoutes(manifest, updated);
|
|
42
|
+
expect(diff.changed).toHaveLength(1);
|
|
43
|
+
expect(diff.unchanged).toHaveLength(0);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('detects unchanged routes', () => {
|
|
47
|
+
const routes = [makeRoute({})];
|
|
48
|
+
const manifest = createManifest(routes);
|
|
49
|
+
const diff = diffRoutes(manifest, routes);
|
|
50
|
+
expect(diff.unchanged).toHaveLength(1);
|
|
51
|
+
expect(diff.added).toHaveLength(0);
|
|
52
|
+
expect(diff.changed).toHaveLength(0);
|
|
53
|
+
expect(diff.removed).toHaveLength(0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('handles null manifest (no previous generation)', () => {
|
|
57
|
+
const routes = [makeRoute({}), makeRoute({ path: '/api/posts' })];
|
|
58
|
+
const diff = diffRoutes(null, routes);
|
|
59
|
+
expect(diff.added).toHaveLength(2);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('detects change when queryParams added', () => {
|
|
63
|
+
const original = [makeRoute({})];
|
|
64
|
+
const manifest = createManifest(original);
|
|
65
|
+
const updated = [makeRoute({ queryParams: [{ name: 'q', type: 'string', required: true }] })];
|
|
66
|
+
const diff = diffRoutes(manifest, updated);
|
|
67
|
+
expect(diff.changed).toHaveLength(1);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { DjangoGenerator } from '../generators/django-generator.js';
|
|
3
|
+
import type { RouteDescriptor } from '../types.js';
|
|
4
|
+
|
|
5
|
+
function makeRoute(overrides: Partial<RouteDescriptor> = {}): RouteDescriptor {
|
|
6
|
+
return {
|
|
7
|
+
path: '/api/users',
|
|
8
|
+
method: 'GET',
|
|
9
|
+
responseFormat: 'json',
|
|
10
|
+
source: { file: 'test.html', line: 5, kind: 'fetch', raw: 'fetch /api/users' },
|
|
11
|
+
pathParams: [],
|
|
12
|
+
handlerName: 'getApiUsers',
|
|
13
|
+
notes: [],
|
|
14
|
+
...overrides,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('DjangoGenerator', () => {
|
|
19
|
+
const gen = new DjangoGenerator();
|
|
20
|
+
|
|
21
|
+
it('has correct framework name', () => {
|
|
22
|
+
expect(gen.framework).toBe('django');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('generates views.py, urls.py, __init__.py, and root urls.py', () => {
|
|
26
|
+
const result = gen.generate([makeRoute()], { outputDir: '/out' });
|
|
27
|
+
const paths = result.files.map(f => f.path);
|
|
28
|
+
expect(paths).toContain('api-users/views.py');
|
|
29
|
+
expect(paths).toContain('api-users/urls.py');
|
|
30
|
+
expect(paths).toContain('api-users/__init__.py');
|
|
31
|
+
expect(paths).toContain('urls.py');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('generates a view function with snake_case name', () => {
|
|
35
|
+
const result = gen.generate([makeRoute()], { outputDir: '/out' });
|
|
36
|
+
const views = result.files.find(f => f.path.endsWith('views.py'))!;
|
|
37
|
+
expect(views.content).toContain('def get_api_users(request):');
|
|
38
|
+
expect(views.content).toContain("return JsonResponse({'message': 'Not implemented'})");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('converts :param to <str:param> in urls.py', () => {
|
|
42
|
+
const route = makeRoute({
|
|
43
|
+
path: '/api/users/:id',
|
|
44
|
+
pathParams: ['id'],
|
|
45
|
+
handlerName: 'getApiUsersById',
|
|
46
|
+
});
|
|
47
|
+
const result = gen.generate([route], { outputDir: '/out' });
|
|
48
|
+
const urls = result.files.find(f => f.path === 'api-users/urls.py')!;
|
|
49
|
+
expect(urls.content).toContain("path('api/users/<str:id>'");
|
|
50
|
+
expect(urls.content).toContain('views.get_api_users_by_id');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('includes path params in view function signature', () => {
|
|
54
|
+
const route = makeRoute({
|
|
55
|
+
path: '/api/users/:id',
|
|
56
|
+
pathParams: ['id'],
|
|
57
|
+
handlerName: 'getApiUsersById',
|
|
58
|
+
});
|
|
59
|
+
const result = gen.generate([route], { outputDir: '/out' });
|
|
60
|
+
const views = result.files.find(f => f.path.endsWith('views.py'))!;
|
|
61
|
+
expect(views.content).toContain('def get_api_users_by_id(request, id):');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('adds require_http_methods decorator for non-GET routes', () => {
|
|
65
|
+
const route = makeRoute({ method: 'POST', handlerName: 'postApiUsers' });
|
|
66
|
+
const result = gen.generate([route], { outputDir: '/out' });
|
|
67
|
+
const views = result.files.find(f => f.path.endsWith('views.py'))!;
|
|
68
|
+
expect(views.content).toContain('@require_http_methods(["POST"])');
|
|
69
|
+
expect(views.content).toContain(
|
|
70
|
+
'from django.views.decorators.http import require_http_methods'
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('parses request body for POST routes', () => {
|
|
75
|
+
const route = makeRoute({
|
|
76
|
+
method: 'POST',
|
|
77
|
+
handlerName: 'postApiUsers',
|
|
78
|
+
requestBody: [
|
|
79
|
+
{ name: 'name', type: 'string', required: true },
|
|
80
|
+
{ name: 'email', type: 'string', required: true },
|
|
81
|
+
],
|
|
82
|
+
});
|
|
83
|
+
const result = gen.generate([route], { outputDir: '/out' });
|
|
84
|
+
const views = result.files.find(f => f.path.endsWith('views.py'))!;
|
|
85
|
+
expect(views.content).toContain('import json');
|
|
86
|
+
expect(views.content).toContain('data = json.loads(request.body)');
|
|
87
|
+
expect(views.content).toContain("name = data.get('name')");
|
|
88
|
+
expect(views.content).toContain("email = data.get('email')");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('generates HTML response for html format', () => {
|
|
92
|
+
const route = makeRoute({ responseFormat: 'html' });
|
|
93
|
+
const result = gen.generate([route], { outputDir: '/out' });
|
|
94
|
+
const views = result.files.find(f => f.path.endsWith('views.py'))!;
|
|
95
|
+
expect(views.content).toContain("return HttpResponse('<div>Not implemented</div>')");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('generates root urls.py with include()', () => {
|
|
99
|
+
const result = gen.generate([makeRoute()], { outputDir: '/out' });
|
|
100
|
+
const rootUrls = result.files.find(f => f.path === 'urls.py')!;
|
|
101
|
+
expect(rootUrls.content).toContain('from django.urls import path, include');
|
|
102
|
+
expect(rootUrls.content).toContain("include('api-users.urls')");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('generates query param extraction', () => {
|
|
106
|
+
const route = makeRoute({
|
|
107
|
+
path: '/api/search',
|
|
108
|
+
handlerName: 'getApiSearch',
|
|
109
|
+
queryParams: [{ name: 'q', type: 'string', required: true }],
|
|
110
|
+
});
|
|
111
|
+
const result = gen.generate([route], { outputDir: '/out' });
|
|
112
|
+
const views = result.files.find(f => f.path.endsWith('views.py'))!;
|
|
113
|
+
expect(views.content).toContain("q = request.GET.get('q')");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('warns when no routes provided', () => {
|
|
117
|
+
const result = gen.generate([], { outputDir: '/out' });
|
|
118
|
+
expect(result.warnings).toContain('No routes to generate');
|
|
119
|
+
expect(result.files).toHaveLength(0);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('includes serverbridge markers', () => {
|
|
123
|
+
const result = gen.generate([makeRoute()], { outputDir: '/out' });
|
|
124
|
+
const views = result.files.find(f => f.path.endsWith('views.py'))!;
|
|
125
|
+
expect(views.content).toContain('# @serverbridge-route: GET /api/users');
|
|
126
|
+
expect(views.content).toContain('# @serverbridge-user-start');
|
|
127
|
+
expect(views.content).toContain('# @serverbridge-user-end');
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { ExpressGenerator } from '../generators/express-generator.js';
|
|
3
|
+
import type { RouteDescriptor } from '../types.js';
|
|
4
|
+
|
|
5
|
+
function makeRoute(overrides: Partial<RouteDescriptor> = {}): RouteDescriptor {
|
|
6
|
+
return {
|
|
7
|
+
path: '/api/users',
|
|
8
|
+
method: 'GET',
|
|
9
|
+
responseFormat: 'json',
|
|
10
|
+
source: { file: 'test.html', kind: 'fetch', raw: 'fetch /api/users' },
|
|
11
|
+
pathParams: [],
|
|
12
|
+
handlerName: 'getApiUsers',
|
|
13
|
+
notes: [],
|
|
14
|
+
...overrides,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('ExpressGenerator', () => {
|
|
19
|
+
const gen = new ExpressGenerator();
|
|
20
|
+
|
|
21
|
+
it('has correct framework name', () => {
|
|
22
|
+
expect(gen.framework).toBe('express');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('generates route files for a GET endpoint', () => {
|
|
26
|
+
const result = gen.generate([makeRoute()], { outputDir: '/out' });
|
|
27
|
+
expect(result.files.length).toBeGreaterThanOrEqual(2); // router file + index
|
|
28
|
+
const routerFile = result.files.find(f => f.path.includes('api-users'));
|
|
29
|
+
expect(routerFile).toBeDefined();
|
|
30
|
+
expect(routerFile!.content).toContain("router.get('/api/users'");
|
|
31
|
+
expect(routerFile!.content).toContain('// @serverbridge-route: GET /api/users');
|
|
32
|
+
expect(routerFile!.content).toContain('// @serverbridge-user-start');
|
|
33
|
+
expect(routerFile!.content).toContain('// @serverbridge-user-end');
|
|
34
|
+
expect(routerFile!.content).toContain("res.json({ message: 'Not implemented' })");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('generates POST route with body destructuring', () => {
|
|
38
|
+
const route = makeRoute({
|
|
39
|
+
path: '/api/users',
|
|
40
|
+
method: 'POST',
|
|
41
|
+
handlerName: 'postApiUsers',
|
|
42
|
+
requestBody: [
|
|
43
|
+
{ name: 'name', type: 'string', required: true },
|
|
44
|
+
{ name: 'email', type: 'string', required: true },
|
|
45
|
+
],
|
|
46
|
+
});
|
|
47
|
+
const result = gen.generate([route], { outputDir: '/out' });
|
|
48
|
+
const routerFile = result.files.find(f => f.path.includes('api-users'));
|
|
49
|
+
expect(routerFile!.content).toContain("router.post('/api/users'");
|
|
50
|
+
expect(routerFile!.content).toContain('const { name, email } = req.body;');
|
|
51
|
+
expect(routerFile!.content).toContain('// Expected body: { name: string, email: string }');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('generates route with path params', () => {
|
|
55
|
+
const route = makeRoute({
|
|
56
|
+
path: '/api/users/:id',
|
|
57
|
+
pathParams: ['id'],
|
|
58
|
+
handlerName: 'getApiUsersById',
|
|
59
|
+
});
|
|
60
|
+
const result = gen.generate([route], { outputDir: '/out' });
|
|
61
|
+
const routerFile = result.files.find(f => f.path.includes('api-users'));
|
|
62
|
+
expect(routerFile!.content).toContain('const { id } = req.params;');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('generates html response for html format', () => {
|
|
66
|
+
const route = makeRoute({ responseFormat: 'html' });
|
|
67
|
+
const result = gen.generate([route], { outputDir: '/out' });
|
|
68
|
+
const routerFile = result.files.find(f => f.path.includes('api-users'));
|
|
69
|
+
expect(routerFile!.content).toContain("res.send('<div>Not implemented</div>')");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('generates index file that imports all routers', () => {
|
|
73
|
+
const routes = [
|
|
74
|
+
makeRoute({ path: '/api/users' }),
|
|
75
|
+
makeRoute({ path: '/api/products', handlerName: 'getApiProducts' }),
|
|
76
|
+
];
|
|
77
|
+
const result = gen.generate(routes, { outputDir: '/out' });
|
|
78
|
+
const indexFile = result.files.find(f => f.path.endsWith('index.ts'));
|
|
79
|
+
expect(indexFile).toBeDefined();
|
|
80
|
+
expect(indexFile!.content).toContain("import apiUsersRouter from './api-users.js'");
|
|
81
|
+
expect(indexFile!.content).toContain("import apiProductsRouter from './api-products.js'");
|
|
82
|
+
expect(indexFile!.content).toContain('router.use(apiUsersRouter)');
|
|
83
|
+
expect(indexFile!.content).toContain('router.use(apiProductsRouter)');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('generates query param destructuring', () => {
|
|
87
|
+
const route = makeRoute({
|
|
88
|
+
path: '/api/search',
|
|
89
|
+
handlerName: 'getApiSearch',
|
|
90
|
+
queryParams: [
|
|
91
|
+
{ name: 'q', type: 'string', required: true },
|
|
92
|
+
{ name: 'limit', type: 'string', required: true },
|
|
93
|
+
],
|
|
94
|
+
});
|
|
95
|
+
const result = gen.generate([route], { outputDir: '/out' });
|
|
96
|
+
const routerFile = result.files.find(f => f.path.includes('api-search'));
|
|
97
|
+
expect(routerFile!.content).toContain('const { q, limit } = req.query;');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('warns when no routes provided', () => {
|
|
101
|
+
const result = gen.generate([], { outputDir: '/out' });
|
|
102
|
+
expect(result.warnings).toContain('No routes to generate');
|
|
103
|
+
expect(result.files).toHaveLength(0);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { FastAPIGenerator } from '../generators/fastapi-generator.js';
|
|
3
|
+
import type { RouteDescriptor } from '../types.js';
|
|
4
|
+
|
|
5
|
+
function makeRoute(overrides: Partial<RouteDescriptor> = {}): RouteDescriptor {
|
|
6
|
+
return {
|
|
7
|
+
path: '/api/users',
|
|
8
|
+
method: 'GET',
|
|
9
|
+
responseFormat: 'json',
|
|
10
|
+
source: { file: 'test.html', line: 5, kind: 'fetch', raw: 'fetch /api/users' },
|
|
11
|
+
pathParams: [],
|
|
12
|
+
handlerName: 'getApiUsers',
|
|
13
|
+
notes: [],
|
|
14
|
+
...overrides,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('FastAPIGenerator', () => {
|
|
19
|
+
const gen = new FastAPIGenerator();
|
|
20
|
+
|
|
21
|
+
it('has correct framework name', () => {
|
|
22
|
+
expect(gen.framework).toBe('fastapi');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('generates router files and main.py', () => {
|
|
26
|
+
const result = gen.generate([makeRoute()], { outputDir: '/out' });
|
|
27
|
+
const paths = result.files.map(f => f.path);
|
|
28
|
+
expect(paths).toContain('routers/api_users.py');
|
|
29
|
+
expect(paths).toContain('main.py');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('generates async endpoint with snake_case name', () => {
|
|
33
|
+
const result = gen.generate([makeRoute()], { outputDir: '/out' });
|
|
34
|
+
const router = result.files.find(f => f.path.endsWith('api_users.py'))!;
|
|
35
|
+
expect(router.content).toContain("@router.get('/api/users')");
|
|
36
|
+
expect(router.content).toContain('async def get_api_users():');
|
|
37
|
+
expect(router.content).toContain("return {'message': 'Not implemented'}");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('converts :param to {param} in paths', () => {
|
|
41
|
+
const route = makeRoute({
|
|
42
|
+
path: '/api/users/:id',
|
|
43
|
+
pathParams: ['id'],
|
|
44
|
+
handlerName: 'getApiUsersById',
|
|
45
|
+
});
|
|
46
|
+
const result = gen.generate([route], { outputDir: '/out' });
|
|
47
|
+
const router = result.files.find(f => f.path.endsWith('api_users.py'))!;
|
|
48
|
+
expect(router.content).toContain("@router.get('/api/users/{id}')");
|
|
49
|
+
expect(router.content).toContain('async def get_api_users_by_id(id: str):');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('generates Pydantic model for POST routes with body', () => {
|
|
53
|
+
const route = makeRoute({
|
|
54
|
+
method: 'POST',
|
|
55
|
+
handlerName: 'postApiUsers',
|
|
56
|
+
requestBody: [
|
|
57
|
+
{ name: 'name', type: 'string', required: true },
|
|
58
|
+
{ name: 'email', type: 'string', required: true },
|
|
59
|
+
{ name: 'age', type: 'number', required: false },
|
|
60
|
+
],
|
|
61
|
+
});
|
|
62
|
+
const result = gen.generate([route], { outputDir: '/out' });
|
|
63
|
+
|
|
64
|
+
// Should generate models.py
|
|
65
|
+
const models = result.files.find(f => f.path === 'models.py')!;
|
|
66
|
+
expect(models).toBeDefined();
|
|
67
|
+
expect(models.content).toContain('from pydantic import BaseModel');
|
|
68
|
+
expect(models.content).toContain('class PostApiUsersBody(BaseModel):');
|
|
69
|
+
expect(models.content).toContain('name: str');
|
|
70
|
+
expect(models.content).toContain('email: str');
|
|
71
|
+
expect(models.content).toContain('age: Optional[int] = None');
|
|
72
|
+
|
|
73
|
+
// Router should reference the model
|
|
74
|
+
const router = result.files.find(f => f.path.endsWith('api_users.py'))!;
|
|
75
|
+
expect(router.content).toContain('from ..models import PostApiUsersBody');
|
|
76
|
+
expect(router.content).toContain('body: PostApiUsersBody');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('does not generate models.py when no routes have bodies', () => {
|
|
80
|
+
const result = gen.generate([makeRoute()], { outputDir: '/out' });
|
|
81
|
+
const models = result.files.find(f => f.path === 'models.py');
|
|
82
|
+
expect(models).toBeUndefined();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('uses HTMLResponse for html format', () => {
|
|
86
|
+
const route = makeRoute({ responseFormat: 'html' });
|
|
87
|
+
const result = gen.generate([route], { outputDir: '/out' });
|
|
88
|
+
const router = result.files.find(f => f.path.endsWith('api_users.py'))!;
|
|
89
|
+
expect(router.content).toContain('from fastapi.responses import HTMLResponse');
|
|
90
|
+
expect(router.content).toContain("return '<div>Not implemented</div>'");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('generates main.py with include_router', () => {
|
|
94
|
+
const result = gen.generate([makeRoute()], { outputDir: '/out' });
|
|
95
|
+
const main = result.files.find(f => f.path === 'main.py')!;
|
|
96
|
+
expect(main.content).toContain('from fastapi import FastAPI');
|
|
97
|
+
expect(main.content).toContain('app = FastAPI()');
|
|
98
|
+
expect(main.content).toContain('app.include_router(api_users_router)');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('generates Query() params in function signature', () => {
|
|
102
|
+
const route = makeRoute({
|
|
103
|
+
path: '/api/search',
|
|
104
|
+
handlerName: 'getApiSearch',
|
|
105
|
+
queryParams: [{ name: 'q', type: 'string', required: true }],
|
|
106
|
+
});
|
|
107
|
+
const result = gen.generate([route], { outputDir: '/out' });
|
|
108
|
+
const router = result.files.find(f => f.path.endsWith('api_search.py'))!;
|
|
109
|
+
expect(router.content).toContain('q: str = Query(...)');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('warns when no routes provided', () => {
|
|
113
|
+
const result = gen.generate([], { outputDir: '/out' });
|
|
114
|
+
expect(result.warnings).toContain('No routes to generate');
|
|
115
|
+
expect(result.files).toHaveLength(0);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('includes serverbridge markers', () => {
|
|
119
|
+
const result = gen.generate([makeRoute()], { outputDir: '/out' });
|
|
120
|
+
const router = result.files.find(f => f.path.endsWith('api_users.py'))!;
|
|
121
|
+
expect(router.content).toContain('# @serverbridge-route: GET /api/users');
|
|
122
|
+
expect(router.content).toContain('# @serverbridge-user-start');
|
|
123
|
+
expect(router.content).toContain('# @serverbridge-user-end');
|
|
124
|
+
});
|
|
125
|
+
});
|