@tamayrp14/fire-api 0.0.0-stage → 1.0.1
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 +276 -2
- package/bin/fire.js +139 -0
- package/bin/fire.mjs +670 -0
- package/bun.lock +20 -0
- package/docs/GETTING_STARTED.md +150 -0
- package/docs/LAUNCH_KIT.md +236 -0
- package/docs/superpowers/plans/2026-11-14-fire-api-framework.md +144 -0
- package/docs/superpowers/specs/fire-api-design-spec.md +111 -0
- package/examples/basic-api/index.ts +177 -0
- package/package.json +68 -4
- package/src/ai/mcp.ts +140 -0
- package/src/auth/auth.ts +110 -0
- package/src/auth/crypto.ts +70 -0
- package/src/auth/jwt.ts +105 -0
- package/src/cache/cache.ts +72 -0
- package/src/cli/bench.ts +118 -0
- package/src/cli/compile.ts +29 -0
- package/src/cli/create-app.ts +284 -0
- package/src/cli/docker.ts +51 -0
- package/src/cli/generator.ts +75 -0
- package/src/cli/routes.ts +76 -0
- package/src/client.ts +120 -0
- package/src/compat/express.ts +139 -0
- package/src/core/app.ts +723 -0
- package/src/core/context.ts +218 -0
- package/src/core/error.ts +42 -0
- package/src/core/router.ts +262 -0
- package/src/db/db-interface.ts +9 -0
- package/src/db/hub.ts +256 -0
- package/src/db/memory-adapter.ts +202 -0
- package/src/db/migrator.ts +100 -0
- package/src/db/sqlite-adapter.ts +58 -0
- package/src/docs/collection.ts +57 -0
- package/src/docs/scalar-ui.ts +25 -0
- package/src/events/event-bus.ts +61 -0
- package/src/index.ts +37 -0
- package/src/jobs/cron.ts +77 -0
- package/src/jobs/queue.ts +108 -0
- package/src/logging/logger.ts +66 -0
- package/src/metrics/metrics.ts +66 -0
- package/src/modules/product/product.route.ts +36 -0
- package/src/modules/product/product.schema.ts +10 -0
- package/src/schema/mock.ts +60 -0
- package/src/schema/openapi.ts +101 -0
- package/src/schema/schema.ts +519 -0
- package/src/schema/validator.ts +5 -0
- package/src/shield/compress.ts +61 -0
- package/src/shield/cors.ts +94 -0
- package/src/shield/headers.ts +55 -0
- package/src/shield/idempotency.ts +76 -0
- package/src/shield/rate-limiter.ts +81 -0
- package/src/shield/sanitizer.ts +23 -0
- package/src/shield/webhook.ts +101 -0
- package/src/static/serve.ts +51 -0
- package/src/storage/storage.ts +176 -0
- package/src/stream/sse.ts +89 -0
- package/src/studio/studio.ts +221 -0
- package/src/types.ts +241 -0
- package/src/ws/ws.ts +118 -0
- package/test/ai-mcp.test.ts +61 -0
- package/test/auth.test.ts +108 -0
- package/test/bench.test.ts +31 -0
- package/test/cache.test.ts +36 -0
- package/test/cli-create.test.ts +64 -0
- package/test/client.test.ts +54 -0
- package/test/collection-export.test.ts +52 -0
- package/test/compat.test.ts +48 -0
- package/test/compress.test.ts +48 -0
- package/test/context.test.ts +48 -0
- package/test/db.test.ts +62 -0
- package/test/docker.test.ts +28 -0
- package/test/events.test.ts +62 -0
- package/test/file-upload.test.ts +116 -0
- package/test/hooks.test.ts +77 -0
- package/test/idempotency.test.ts +65 -0
- package/test/integration.test.ts +111 -0
- package/test/jobs.test.ts +60 -0
- package/test/metrics.test.ts +40 -0
- package/test/mock.test.ts +33 -0
- package/test/multi-tenant.test.ts +80 -0
- package/test/observability.test.ts +95 -0
- package/test/openapi.test.ts +41 -0
- package/test/orm-relations.test.ts +85 -0
- package/test/orm-shortcuts.test.ts +52 -0
- package/test/rbac.test.ts +85 -0
- package/test/router-fastpath.test.ts +42 -0
- package/test/router.test.ts +84 -0
- package/test/schema.test.ts +49 -0
- package/test/seeder-migrator.test.ts +78 -0
- package/test/shield.test.ts +55 -0
- package/test/shorthand-dx.test.ts +31 -0
- package/test/sse.test.ts +43 -0
- package/test/static-serve.test.ts +53 -0
- package/test/storage.test.ts +31 -0
- package/test/studio.test.ts +43 -0
- package/test/tx.test.ts +92 -0
- package/test/webhook.test.ts +113 -0
- package/test/ws.test.ts +54 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { hashPassword, verifyPassword } from '../src/auth/crypto';
|
|
3
|
+
import { signJWT, verifyJWT } from '../src/auth/jwt';
|
|
4
|
+
import { createAuth, requireAuth } from '../src/auth/auth';
|
|
5
|
+
import { createApp } from '../src/core/app';
|
|
6
|
+
|
|
7
|
+
describe('FireAPI Turnkey Auth Module (fire-api/auth)', () => {
|
|
8
|
+
it('hashes and verifies passwords securely using Web Crypto', async () => {
|
|
9
|
+
const rawPassword = 'SuperSecretPassword123!';
|
|
10
|
+
const hash = await hashPassword(rawPassword);
|
|
11
|
+
|
|
12
|
+
expect(hash).toContain(':'); // salt:hash format
|
|
13
|
+
expect(await verifyPassword(rawPassword, hash)).toBe(true);
|
|
14
|
+
expect(await verifyPassword('WrongPassword', hash)).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('signs and verifies JWT tokens with expiration', async () => {
|
|
18
|
+
const secret = 'test-secret-key-super-safe';
|
|
19
|
+
const payload = { userId: 'usr_1', role: 'admin' };
|
|
20
|
+
|
|
21
|
+
const token = await signJWT(payload, secret, 3600); // 1 hour
|
|
22
|
+
expect(token.split('.').length).toBe(3);
|
|
23
|
+
|
|
24
|
+
const decoded = await verifyJWT<{ userId: string; role: string }>(token, secret);
|
|
25
|
+
expect(decoded.userId).toBe('usr_1');
|
|
26
|
+
expect(decoded.role).toBe('admin');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('provides turnkey register and login routes', async () => {
|
|
30
|
+
const app = createApp({ database: { driver: 'memory' } });
|
|
31
|
+
const authRouter = createAuth({ jwtSecret: 'jwt-super-secret' });
|
|
32
|
+
app.use('/auth', authRouter);
|
|
33
|
+
|
|
34
|
+
// 1. Test Register
|
|
35
|
+
const regRes = await app.handle(
|
|
36
|
+
new Request('http://localhost:3000/auth/register', {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
headers: { 'Content-Type': 'application/json' },
|
|
39
|
+
body: JSON.stringify({
|
|
40
|
+
email: 'yoga@fire.dev',
|
|
41
|
+
password: 'Password123!',
|
|
42
|
+
name: 'Yoga',
|
|
43
|
+
}),
|
|
44
|
+
})
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
expect(regRes.status).toBe(201);
|
|
48
|
+
const regData = await regRes.json();
|
|
49
|
+
expect(regData.user.email).toBe('yoga@fire.dev');
|
|
50
|
+
expect(regData.token).toBeDefined();
|
|
51
|
+
|
|
52
|
+
// 2. Test Login (Success)
|
|
53
|
+
const loginRes = await app.handle(
|
|
54
|
+
new Request('http://localhost:3000/auth/login', {
|
|
55
|
+
method: 'POST',
|
|
56
|
+
headers: { 'Content-Type': 'application/json' },
|
|
57
|
+
body: JSON.stringify({
|
|
58
|
+
email: 'yoga@fire.dev',
|
|
59
|
+
password: 'Password123!',
|
|
60
|
+
}),
|
|
61
|
+
})
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
expect(loginRes.status).toBe(200);
|
|
65
|
+
const loginData = await loginRes.json();
|
|
66
|
+
expect(loginData.token).toBeDefined();
|
|
67
|
+
|
|
68
|
+
// 3. Test Login (Invalid password)
|
|
69
|
+
const failRes = await app.handle(
|
|
70
|
+
new Request('http://localhost:3000/auth/login', {
|
|
71
|
+
method: 'POST',
|
|
72
|
+
headers: { 'Content-Type': 'application/json' },
|
|
73
|
+
body: JSON.stringify({
|
|
74
|
+
email: 'yoga@fire.dev',
|
|
75
|
+
password: 'WrongPassword',
|
|
76
|
+
}),
|
|
77
|
+
})
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
expect(failRes.status).toBe(401);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('enforces route protection with requireAuth guard', async () => {
|
|
84
|
+
const app = createApp();
|
|
85
|
+
const secret = 'jwt-guard-secret';
|
|
86
|
+
|
|
87
|
+
app.get('/protected', {
|
|
88
|
+
middlewares: [requireAuth(secret)],
|
|
89
|
+
handler: (ctx) => ctx.ok({ secretData: 'access granted', user: ctx.user }),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Without token -> 401
|
|
93
|
+
const unauthRes = await app.handle(new Request('http://localhost:3000/protected'));
|
|
94
|
+
expect(unauthRes.status).toBe(401);
|
|
95
|
+
|
|
96
|
+
// With valid token -> 200
|
|
97
|
+
const token = await signJWT({ id: 'u1', name: 'Dev' }, secret);
|
|
98
|
+
const authRes = await app.handle(
|
|
99
|
+
new Request('http://localhost:3000/protected', {
|
|
100
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
101
|
+
})
|
|
102
|
+
);
|
|
103
|
+
expect(authRes.status).toBe(200);
|
|
104
|
+
const data = await authRes.json();
|
|
105
|
+
expect(data.secretData).toBe('access granted');
|
|
106
|
+
expect(data.user.id).toBe('u1');
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { runBenchmark, formatBenchmarkReport } from '../src/cli/bench';
|
|
3
|
+
|
|
4
|
+
describe('FireAPI Built-in Benchmark Harness CLI', () => {
|
|
5
|
+
it('measures throughput and percentiles from mock load', async () => {
|
|
6
|
+
// Simulated fast API endpoint returning in 0.5ms - 1ms
|
|
7
|
+
const mockEndpoint = async () => {
|
|
8
|
+
await new Promise((r) => setTimeout(r, 1));
|
|
9
|
+
return new Response(JSON.stringify({ status: 'ok' }));
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const stats = await runBenchmark('http://localhost:3000/mock', {
|
|
13
|
+
concurrency: 5,
|
|
14
|
+
totalRequests: 50,
|
|
15
|
+
customFetch: mockEndpoint,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
expect(stats.totalRequests).toBe(50);
|
|
19
|
+
expect(stats.successfulRequests).toBe(50);
|
|
20
|
+
expect(stats.failedRequests).toBe(0);
|
|
21
|
+
expect(stats.rps).toBeGreaterThan(0);
|
|
22
|
+
expect(stats.latency.p50).toBeGreaterThanOrEqual(0);
|
|
23
|
+
expect(stats.latency.p99).toBeGreaterThanOrEqual(stats.latency.p50);
|
|
24
|
+
|
|
25
|
+
const report = formatBenchmarkReport(stats);
|
|
26
|
+
expect(report).toContain('FireAPI Benchmark Engine');
|
|
27
|
+
expect(report).toContain('Requests/sec:');
|
|
28
|
+
expect(report).toContain('P50:');
|
|
29
|
+
expect(report).toContain('P99:');
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { MemoryCacheManager } from '../src/cache/cache';
|
|
3
|
+
|
|
4
|
+
describe('FireAPI FireCache SWR Engine', () => {
|
|
5
|
+
it('stores and retrieves cached values with TTL', async () => {
|
|
6
|
+
const cache = new MemoryCacheManager();
|
|
7
|
+
await cache.set('user:1', { name: 'Yoga' }, 10); // 10 seconds
|
|
8
|
+
|
|
9
|
+
const item = await cache.get('user:1');
|
|
10
|
+
expect(item).toEqual({ name: 'Yoga' });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('expires items when TTL has elapsed', async () => {
|
|
14
|
+
const cache = new MemoryCacheManager();
|
|
15
|
+
// Expire immediately (0.01s)
|
|
16
|
+
await cache.set('temp', 'gone_soon', 0.01);
|
|
17
|
+
|
|
18
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
19
|
+
const item = await cache.get('temp');
|
|
20
|
+
expect(item).toBeNull();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('invalidates cache entries by tags', async () => {
|
|
24
|
+
const cache = new MemoryCacheManager();
|
|
25
|
+
await cache.set('post:1', { title: 'First Post' }, 60, ['posts', 'author:1']);
|
|
26
|
+
await cache.set('post:2', { title: 'Second Post' }, 60, ['posts', 'author:2']);
|
|
27
|
+
await cache.set('user:1', { name: 'Author One' }, 60, ['users']);
|
|
28
|
+
|
|
29
|
+
// Invalidate 'posts' tag
|
|
30
|
+
await cache.invalidate('posts');
|
|
31
|
+
|
|
32
|
+
expect(await cache.get('post:1')).toBeNull();
|
|
33
|
+
expect(await cache.get('post:2')).toBeNull();
|
|
34
|
+
expect(await cache.get('user:1')).toEqual({ name: 'Author One' }); // untouched!
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, it, afterAll } from 'bun:test';
|
|
2
|
+
import { createAppProject, formatRoutesTable, createRouter } from '../src/index';
|
|
3
|
+
import * as fs from 'fs';
|
|
4
|
+
import * as path from 'path';
|
|
5
|
+
|
|
6
|
+
describe('FireAPI CLI Scaffolding & Developer Accelerator (create-app & routes)', () => {
|
|
7
|
+
const tempProjectDir = path.resolve('./temp_scaffold_test_app');
|
|
8
|
+
|
|
9
|
+
afterAll(() => {
|
|
10
|
+
if (fs.existsSync(tempProjectDir)) {
|
|
11
|
+
fs.rmSync(tempProjectDir, { recursive: true, force: true });
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('scaffolds a complete, clean, modern FireAPI project with all files', () => {
|
|
16
|
+
const result = createAppProject({
|
|
17
|
+
name: 'test-saas-api',
|
|
18
|
+
targetDir: tempProjectDir,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
expect(result.filesCreated.length).toBeGreaterThanOrEqual(10);
|
|
22
|
+
expect(fs.existsSync(path.join(tempProjectDir, 'package.json'))).toBe(true);
|
|
23
|
+
expect(fs.existsSync(path.join(tempProjectDir, 'tsconfig.json'))).toBe(true);
|
|
24
|
+
expect(fs.existsSync(path.join(tempProjectDir, '.env'))).toBe(true);
|
|
25
|
+
expect(fs.existsSync(path.join(tempProjectDir, '.gitignore'))).toBe(true);
|
|
26
|
+
expect(fs.existsSync(path.join(tempProjectDir, 'src/index.ts'))).toBe(true);
|
|
27
|
+
expect(fs.existsSync(path.join(tempProjectDir, 'src/modules/users/users.router.ts'))).toBe(true);
|
|
28
|
+
expect(fs.existsSync(path.join(tempProjectDir, 'src/modules/users/users.schema.ts'))).toBe(true);
|
|
29
|
+
expect(fs.existsSync(path.join(tempProjectDir, 'src/modules/auth/auth.router.ts'))).toBe(true);
|
|
30
|
+
expect(fs.existsSync(path.join(tempProjectDir, 'test/app.test.ts'))).toBe(true);
|
|
31
|
+
|
|
32
|
+
// Verify package.json contains correct scripts
|
|
33
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(tempProjectDir, 'package.json'), 'utf-8'));
|
|
34
|
+
expect(pkg.name).toBe('test-saas-api');
|
|
35
|
+
expect(pkg.scripts.dev).toContain('bun');
|
|
36
|
+
expect(pkg.scripts.compile).toContain('fire compile');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('renders a visual terminal route table via formatRoutesTable()', () => {
|
|
40
|
+
const router = createRouter('/api/v1');
|
|
41
|
+
|
|
42
|
+
router.get('/items', {
|
|
43
|
+
cache: { ttl: 60 },
|
|
44
|
+
ai: { description: 'Fetch all catalog items' },
|
|
45
|
+
handler: (ctx) => ctx.ok([]),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
router.post('/items', {
|
|
49
|
+
idempotent: true,
|
|
50
|
+
roles: ['admin', 'manager'],
|
|
51
|
+
ai: { description: 'Create a new catalog item' },
|
|
52
|
+
handler: (ctx) => ctx.created({}),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const output = formatRoutesTable(router);
|
|
56
|
+
|
|
57
|
+
expect(output).toContain('FireAPI Route Manifest');
|
|
58
|
+
expect(output).toContain('GET');
|
|
59
|
+
expect(output).toContain('/api/v1/items');
|
|
60
|
+
expect(output).toContain('POST');
|
|
61
|
+
expect(output).toContain('admin, manager');
|
|
62
|
+
expect(output).toContain('Fetch all catalog items');
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { createClient } from '../src/client';
|
|
3
|
+
|
|
4
|
+
describe('FireAPI FireClient E2E Type-Safe RPC', () => {
|
|
5
|
+
it('constructs correct HTTP requests from proxy path calls', async () => {
|
|
6
|
+
let capturedUrl = '';
|
|
7
|
+
let capturedMethod = '';
|
|
8
|
+
let capturedBody = '';
|
|
9
|
+
|
|
10
|
+
const mockFetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
11
|
+
capturedUrl = input.toString();
|
|
12
|
+
capturedMethod = init?.method || 'GET';
|
|
13
|
+
capturedBody = init?.body ? String(init.body) : '';
|
|
14
|
+
return new Response(JSON.stringify({ ok: true, id: '123' }), {
|
|
15
|
+
status: 200,
|
|
16
|
+
headers: { 'Content-Type': 'application/json' },
|
|
17
|
+
});
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const client = createClient('http://localhost:3000', { fetch: mockFetch });
|
|
21
|
+
|
|
22
|
+
// Call GET /api/v1/health
|
|
23
|
+
const res1 = await client.api.v1.health.get();
|
|
24
|
+
expect(capturedUrl).toBe('http://localhost:3000/api/v1/health');
|
|
25
|
+
expect(capturedMethod).toBe('GET');
|
|
26
|
+
expect(res1.ok).toBe(true);
|
|
27
|
+
expect(res1.data).toEqual({ ok: true, id: '123' });
|
|
28
|
+
|
|
29
|
+
// Call POST /users with body
|
|
30
|
+
const res2 = await client.users.post({
|
|
31
|
+
body: { name: 'Alice', email: 'alice@fire.dev' },
|
|
32
|
+
});
|
|
33
|
+
expect(capturedUrl).toBe('http://localhost:3000/users');
|
|
34
|
+
expect(capturedMethod).toBe('POST');
|
|
35
|
+
expect(JSON.parse(capturedBody)).toEqual({ name: 'Alice', email: 'alice@fire.dev' });
|
|
36
|
+
|
|
37
|
+
// Call GET /users/:id with params substitution
|
|
38
|
+
await client.users[':id'].get({ params: { id: 'usr_99' } });
|
|
39
|
+
expect(capturedUrl).toBe('http://localhost:3000/users/usr_99');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('handles query parameters accurately', async () => {
|
|
43
|
+
let capturedUrl = '';
|
|
44
|
+
const mockFetch = async (input: RequestInfo | URL) => {
|
|
45
|
+
capturedUrl = input.toString();
|
|
46
|
+
return new Response(JSON.stringify([]));
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const client = createClient('http://localhost:3000', { fetch: mockFetch });
|
|
50
|
+
await client.posts.get({ query: { page: '2', limit: '10' } });
|
|
51
|
+
|
|
52
|
+
expect(capturedUrl).toBe('http://localhost:3000/posts?page=2&limit=10');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { createApp, createRouter, s } from '../src/index';
|
|
3
|
+
|
|
4
|
+
describe('FireAPI One-Click Postman & Bruno Collection Exporter', () => {
|
|
5
|
+
it('serves Postman Collection v2.1.0 specification at /docs/collection.json', async () => {
|
|
6
|
+
const app = createApp({
|
|
7
|
+
docs: '/docs',
|
|
8
|
+
openapiPath: '/openapi.json',
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const router = createRouter('/articles');
|
|
12
|
+
router.get('/', {
|
|
13
|
+
ai: { description: 'Get all articles' },
|
|
14
|
+
handler: (ctx) => ctx.ok([]),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
router.post('/', {
|
|
18
|
+
schema: {
|
|
19
|
+
body: s.object({
|
|
20
|
+
title: s.string().min(5),
|
|
21
|
+
content: s.string().min(10),
|
|
22
|
+
}),
|
|
23
|
+
},
|
|
24
|
+
ai: { description: 'Create an article' },
|
|
25
|
+
handler: (ctx) => ctx.created(ctx.body),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
app.use(router);
|
|
29
|
+
|
|
30
|
+
const res = await app.handle(new Request('http://localhost:3000/docs/collection.json'));
|
|
31
|
+
expect(res.status).toBe(200);
|
|
32
|
+
|
|
33
|
+
const collection = await res.json();
|
|
34
|
+
expect(collection.info.schema).toContain('v2.1.0');
|
|
35
|
+
expect(Array.isArray(collection.item)).toBe(true);
|
|
36
|
+
|
|
37
|
+
const getArticle = collection.item.find((i: any) => i.name === 'GET /articles');
|
|
38
|
+
expect(getArticle).toBeDefined();
|
|
39
|
+
expect(getArticle.request.method).toBe('GET');
|
|
40
|
+
|
|
41
|
+
const postArticle = collection.item.find((i: any) => i.name === 'POST /articles');
|
|
42
|
+
expect(postArticle).toBeDefined();
|
|
43
|
+
expect(postArticle.request.method).toBe('POST');
|
|
44
|
+
expect(postArticle.request.body).toBeDefined();
|
|
45
|
+
expect(postArticle.request.body.mode).toBe('raw');
|
|
46
|
+
|
|
47
|
+
// Parse the auto-generated mock body inside the collection!
|
|
48
|
+
const mockPayload = JSON.parse(postArticle.request.body.raw);
|
|
49
|
+
expect(typeof mockPayload.title).toBe('string');
|
|
50
|
+
expect(typeof mockPayload.content).toBe('string');
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { fromExpress } from '../src/compat/express';
|
|
3
|
+
import { createApp } from '../src/core/app';
|
|
4
|
+
|
|
5
|
+
describe('FireAPI Express Compatibility Bridge (fire-api/compat)', () => {
|
|
6
|
+
it('bridges standard Express middleware that modifies headers or state', async () => {
|
|
7
|
+
const app = createApp();
|
|
8
|
+
|
|
9
|
+
// Standard Express middleware: (req, res, next)
|
|
10
|
+
const expressLogger = (req: any, res: any, next: any) => {
|
|
11
|
+
req.customId = 'exp_123';
|
|
12
|
+
res.setHeader('X-Express-Bridged', 'true');
|
|
13
|
+
next();
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
app.get('/test-compat', {
|
|
17
|
+
middlewares: [fromExpress(expressLogger)],
|
|
18
|
+
handler: (ctx) => {
|
|
19
|
+
return ctx.ok({ bridgedId: (ctx.req as any).customId });
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const res = await app.handle(new Request('http://localhost:3000/test-compat'));
|
|
24
|
+
expect(res.status).toBe(200);
|
|
25
|
+
expect(res.headers.get('x-express-bridged')).toBe('true');
|
|
26
|
+
const data = await res.json();
|
|
27
|
+
expect(data.bridgedId).toBe('exp_123');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('handles early termination from Express middleware (e.g. res.status().json())', async () => {
|
|
31
|
+
const app = createApp();
|
|
32
|
+
|
|
33
|
+
// Middleware that intercepts and rejects early
|
|
34
|
+
const expressBlocker = (_req: any, res: any, _next: any) => {
|
|
35
|
+
res.status(403).json({ blockedBy: 'express-guard' });
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
app.get('/blocked', {
|
|
39
|
+
middlewares: [fromExpress(expressBlocker)],
|
|
40
|
+
handler: (ctx) => ctx.ok({ secret: 'never reached' }),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const res = await app.handle(new Request('http://localhost:3000/blocked'));
|
|
44
|
+
expect(res.status).toBe(403);
|
|
45
|
+
const data = await res.json();
|
|
46
|
+
expect(data.blockedBy).toBe('express-guard');
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { compressResponse } from '../src/shield/compress';
|
|
3
|
+
|
|
4
|
+
describe('FireAPI Native HTTP Stream Compression (Gzip / Web Standard)', () => {
|
|
5
|
+
it('compresses JSON response when client accepts gzip and payload exceeds threshold', async () => {
|
|
6
|
+
// Generate JSON payload larger than 1KB
|
|
7
|
+
const largeData = { items: Array.from({ length: 100 }, (_, i) => ({ id: i, name: `Item ${i}` })) };
|
|
8
|
+
const rawResponse = new Response(JSON.stringify(largeData), {
|
|
9
|
+
headers: { 'Content-Type': 'application/json' },
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const reqWithGzip = new Request('http://localhost:3000/items', {
|
|
13
|
+
headers: { 'Accept-Encoding': 'gzip, deflate' },
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const compressed = await compressResponse(rawResponse, reqWithGzip, { threshold: 100 });
|
|
17
|
+
|
|
18
|
+
expect(compressed.headers.get('Content-Encoding')).toBe('gzip');
|
|
19
|
+
expect(compressed.headers.get('Vary')).toContain('Accept-Encoding');
|
|
20
|
+
|
|
21
|
+
// Decompress with DecompressionStream to verify integrity
|
|
22
|
+
const decompressedStream = compressed.body!.pipeThrough(new DecompressionStream('gzip'));
|
|
23
|
+
const text = await new Response(decompressedStream).text();
|
|
24
|
+
expect(JSON.parse(text)).toEqual(largeData);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('skips compression if payload is smaller than threshold', async () => {
|
|
28
|
+
const tinyResponse = new Response(JSON.stringify({ ok: true }), {
|
|
29
|
+
headers: { 'Content-Type': 'application/json' },
|
|
30
|
+
});
|
|
31
|
+
const reqWithGzip = new Request('http://localhost:3000/tiny', {
|
|
32
|
+
headers: { 'Accept-Encoding': 'gzip' },
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const result = await compressResponse(tinyResponse, reqWithGzip, { threshold: 1000 });
|
|
36
|
+
expect(result.headers.get('Content-Encoding')).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('skips compression if client does not accept gzip', async () => {
|
|
40
|
+
const rawResponse = new Response(JSON.stringify({ text: 'a'.repeat(2000) }), {
|
|
41
|
+
headers: { 'Content-Type': 'application/json' },
|
|
42
|
+
});
|
|
43
|
+
const reqWithoutGzip = new Request('http://localhost:3000/no-gzip');
|
|
44
|
+
|
|
45
|
+
const result = await compressResponse(rawResponse, reqWithoutGzip, { threshold: 100 });
|
|
46
|
+
expect(result.headers.get('Content-Encoding')).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { createContext } from '../src/core/context';
|
|
3
|
+
import { HttpError } from '../src/core/error';
|
|
4
|
+
|
|
5
|
+
describe('FireAPI FireContext & Error Handling', () => {
|
|
6
|
+
it('parses URL, query parameters and route parameters', () => {
|
|
7
|
+
const req = new Request('http://localhost:3000/users/42?filter=active&sort=desc');
|
|
8
|
+
const ctx = createContext({
|
|
9
|
+
req,
|
|
10
|
+
params: { id: '42' },
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
expect(ctx.params.id).toBe('42');
|
|
14
|
+
expect(ctx.query.filter).toBe('active');
|
|
15
|
+
expect(ctx.query.sort).toBe('desc');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('produces standard JSON responses with status codes', async () => {
|
|
19
|
+
const req = new Request('http://localhost:3000/items');
|
|
20
|
+
const ctx = createContext({ req });
|
|
21
|
+
|
|
22
|
+
const okRes = ctx.ok({ success: true });
|
|
23
|
+
expect(okRes.status).toBe(200);
|
|
24
|
+
expect(await okRes.json()).toEqual({ success: true });
|
|
25
|
+
|
|
26
|
+
const createdRes = ctx.created({ id: 'new_1' });
|
|
27
|
+
expect(createdRes.status).toBe(201);
|
|
28
|
+
expect(await createdRes.json()).toEqual({ id: 'new_1' });
|
|
29
|
+
|
|
30
|
+
const notFoundRes = ctx.notFound('Item not found');
|
|
31
|
+
expect(notFoundRes.status).toBe(404);
|
|
32
|
+
expect(await notFoundRes.json()).toEqual({
|
|
33
|
+
statusCode: 404,
|
|
34
|
+
error: 'Not Found',
|
|
35
|
+
message: 'Item not found',
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('supports custom HttpError throwing and serialization', () => {
|
|
40
|
+
const err = new HttpError(400, 'Invalid payload', { field: 'email' });
|
|
41
|
+
expect(err.statusCode).toBe(400);
|
|
42
|
+
expect(err.message).toBe('Invalid payload');
|
|
43
|
+
expect(err.details).toEqual({ field: 'email' });
|
|
44
|
+
|
|
45
|
+
const response = err.toResponse();
|
|
46
|
+
expect(response.status).toBe(400);
|
|
47
|
+
});
|
|
48
|
+
});
|
package/test/db.test.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { MemoryDatabase } from '../src/db/memory-adapter';
|
|
3
|
+
import { createDatabaseHub } from '../src/db/hub';
|
|
4
|
+
|
|
5
|
+
describe('FireAPI FireDB Universal Hub & Memory Adapter', () => {
|
|
6
|
+
it('supports insert, find, findById, and where filtering', async () => {
|
|
7
|
+
const db = new MemoryDatabase();
|
|
8
|
+
|
|
9
|
+
const inserted = await db.table('users').insert({
|
|
10
|
+
name: 'Yoga',
|
|
11
|
+
email: 'yoga@fire.dev',
|
|
12
|
+
role: 'admin',
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
expect(inserted.id).toBeDefined();
|
|
16
|
+
expect(inserted.name).toBe('Yoga');
|
|
17
|
+
|
|
18
|
+
const users = await db.table('users').where('role', 'admin').find();
|
|
19
|
+
expect(users.length).toBe(1);
|
|
20
|
+
expect(users[0].email).toBe('yoga@fire.dev');
|
|
21
|
+
|
|
22
|
+
const userById = await db.table('users').findById(inserted.id);
|
|
23
|
+
expect(userById?.name).toBe('Yoga');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('supports update and delete operations', async () => {
|
|
27
|
+
const db = new MemoryDatabase();
|
|
28
|
+
|
|
29
|
+
const item = await db.table('products').insert({ title: 'MacBook', price: 2000 });
|
|
30
|
+
|
|
31
|
+
// Update
|
|
32
|
+
const updated = await db.table('products').where('id', item.id).update({ price: 1800 });
|
|
33
|
+
expect(updated.price).toBe(1800);
|
|
34
|
+
|
|
35
|
+
// Delete
|
|
36
|
+
const deleted = await db.table('products').where('id', item.id).delete();
|
|
37
|
+
expect(deleted.count).toBe(1);
|
|
38
|
+
|
|
39
|
+
const remaining = await db.table('products').find();
|
|
40
|
+
expect(remaining.length).toBe(0);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('supports transactions in memory adapter', async () => {
|
|
44
|
+
const db = new MemoryDatabase();
|
|
45
|
+
|
|
46
|
+
await db.transaction(async (trx) => {
|
|
47
|
+
await trx.table('logs').insert({ action: 'audit_start' });
|
|
48
|
+
await trx.table('logs').insert({ action: 'audit_end' });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const logs = await db.table('logs').find();
|
|
52
|
+
expect(logs.length).toBe(2);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('initializes seamlessly via createDatabaseHub', async () => {
|
|
56
|
+
const hub = createDatabaseHub({ driver: 'memory', autoMock: true });
|
|
57
|
+
expect(hub).toBeDefined();
|
|
58
|
+
|
|
59
|
+
const res = await hub.table('test').insert({ status: 'ready' });
|
|
60
|
+
expect(res.status).toBe('ready');
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { generateDockerFiles } from '../src/cli/docker';
|
|
5
|
+
|
|
6
|
+
describe('FireAPI One-Command Distroless Docker Synthesizer', () => {
|
|
7
|
+
it('generates production-hardened multi-stage Dockerfile and .dockerignore', () => {
|
|
8
|
+
const testDir = path.join(process.cwd(), 'temp_test_docker');
|
|
9
|
+
if (!fs.existsSync(testDir)) fs.mkdirSync(testDir, { recursive: true });
|
|
10
|
+
|
|
11
|
+
const result = generateDockerFiles(testDir);
|
|
12
|
+
|
|
13
|
+
expect(fs.existsSync(result.dockerfilePath)).toBe(true);
|
|
14
|
+
expect(fs.existsSync(result.dockerignorePath)).toBe(true);
|
|
15
|
+
|
|
16
|
+
const dockerfileContent = fs.readFileSync(result.dockerfilePath, 'utf-8');
|
|
17
|
+
expect(dockerfileContent).toContain('FROM oven/bun:alpine AS base');
|
|
18
|
+
expect(dockerfileContent).toContain('USER appuser');
|
|
19
|
+
expect(dockerfileContent).toContain('EXPOSE 3000');
|
|
20
|
+
|
|
21
|
+
const ignoreContent = fs.readFileSync(result.dockerignorePath, 'utf-8');
|
|
22
|
+
expect(ignoreContent).toContain('node_modules');
|
|
23
|
+
expect(ignoreContent).toContain('.git');
|
|
24
|
+
|
|
25
|
+
// Clean up
|
|
26
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { createApp, createRouter } from '../src/index';
|
|
3
|
+
|
|
4
|
+
describe('FireAPI Typed In-Process Event Bus (app.on, ctx.emit)', () => {
|
|
5
|
+
it('subscribes to and receives typed events triggered via ctx.emit()', async () => {
|
|
6
|
+
const app = createApp();
|
|
7
|
+
const router = createRouter();
|
|
8
|
+
|
|
9
|
+
let eventReceived: any = null;
|
|
10
|
+
|
|
11
|
+
app.on('order.paid', async (payload) => {
|
|
12
|
+
eventReceived = payload;
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
router.post('/checkout', async (ctx) => {
|
|
16
|
+
await ctx.emit('order.paid', { orderId: 'ORD_999', total: 450 });
|
|
17
|
+
return ctx.ok({ status: 'dispatched' });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
app.use(router);
|
|
21
|
+
|
|
22
|
+
const res = await app.handle(
|
|
23
|
+
new Request('http://localhost:3000/checkout', { method: 'POST' })
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
expect(res.status).toBe(200);
|
|
27
|
+
expect(eventReceived).not.toBeNull();
|
|
28
|
+
expect(eventReceived.orderId).toBe('ORD_999');
|
|
29
|
+
expect(eventReceived.total).toBe(450);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('supports wildcard listeners (e.g. user.*)', async () => {
|
|
33
|
+
const app = createApp();
|
|
34
|
+
const capturedEvents: string[] = [];
|
|
35
|
+
|
|
36
|
+
app.on('user.*', async (payload) => {
|
|
37
|
+
capturedEvents.push(payload.action);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
await app.emit('user.registered', { action: 'REGISTER' });
|
|
41
|
+
await app.emit('user.login', { action: 'LOGIN' });
|
|
42
|
+
await app.emit('order.created', { action: 'ORDER' }); // Should NOT match
|
|
43
|
+
|
|
44
|
+
expect(capturedEvents).toEqual(['REGISTER', 'LOGIN']);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('allows unsubscribing from events cleanly', async () => {
|
|
48
|
+
const app = createApp();
|
|
49
|
+
let callCount = 0;
|
|
50
|
+
|
|
51
|
+
const unsubscribe = app.on('ping', () => {
|
|
52
|
+
callCount++;
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
await app.emit('ping', {});
|
|
56
|
+
expect(callCount).toBe(1);
|
|
57
|
+
|
|
58
|
+
unsubscribe();
|
|
59
|
+
await app.emit('ping', {});
|
|
60
|
+
expect(callCount).toBe(1); // Not called after unsubscribe
|
|
61
|
+
});
|
|
62
|
+
});
|