@tamayrp14/fire-api 0.0.0-stage → 1.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.
Files changed (98) hide show
  1. package/README.md +276 -2
  2. package/bin/fire.js +139 -0
  3. package/bun.lock +20 -0
  4. package/docs/GETTING_STARTED.md +150 -0
  5. package/docs/LAUNCH_KIT.md +236 -0
  6. package/docs/superpowers/plans/2026-11-14-fire-api-framework.md +144 -0
  7. package/docs/superpowers/specs/fire-api-design-spec.md +111 -0
  8. package/examples/basic-api/index.ts +177 -0
  9. package/package.json +68 -4
  10. package/src/ai/mcp.ts +140 -0
  11. package/src/auth/auth.ts +110 -0
  12. package/src/auth/crypto.ts +70 -0
  13. package/src/auth/jwt.ts +105 -0
  14. package/src/cache/cache.ts +72 -0
  15. package/src/cli/bench.ts +118 -0
  16. package/src/cli/compile.ts +29 -0
  17. package/src/cli/create-app.ts +284 -0
  18. package/src/cli/docker.ts +51 -0
  19. package/src/cli/generator.ts +75 -0
  20. package/src/cli/routes.ts +76 -0
  21. package/src/client.ts +120 -0
  22. package/src/compat/express.ts +139 -0
  23. package/src/core/app.ts +723 -0
  24. package/src/core/context.ts +218 -0
  25. package/src/core/error.ts +42 -0
  26. package/src/core/router.ts +262 -0
  27. package/src/db/db-interface.ts +9 -0
  28. package/src/db/hub.ts +256 -0
  29. package/src/db/memory-adapter.ts +202 -0
  30. package/src/db/migrator.ts +100 -0
  31. package/src/db/sqlite-adapter.ts +58 -0
  32. package/src/docs/collection.ts +57 -0
  33. package/src/docs/scalar-ui.ts +25 -0
  34. package/src/events/event-bus.ts +61 -0
  35. package/src/index.ts +37 -0
  36. package/src/jobs/cron.ts +77 -0
  37. package/src/jobs/queue.ts +108 -0
  38. package/src/logging/logger.ts +66 -0
  39. package/src/metrics/metrics.ts +66 -0
  40. package/src/modules/product/product.route.ts +36 -0
  41. package/src/modules/product/product.schema.ts +10 -0
  42. package/src/schema/mock.ts +60 -0
  43. package/src/schema/openapi.ts +101 -0
  44. package/src/schema/schema.ts +519 -0
  45. package/src/schema/validator.ts +5 -0
  46. package/src/shield/compress.ts +61 -0
  47. package/src/shield/cors.ts +94 -0
  48. package/src/shield/headers.ts +55 -0
  49. package/src/shield/idempotency.ts +76 -0
  50. package/src/shield/rate-limiter.ts +81 -0
  51. package/src/shield/sanitizer.ts +23 -0
  52. package/src/shield/webhook.ts +101 -0
  53. package/src/static/serve.ts +51 -0
  54. package/src/storage/storage.ts +176 -0
  55. package/src/stream/sse.ts +89 -0
  56. package/src/studio/studio.ts +221 -0
  57. package/src/types.ts +241 -0
  58. package/src/ws/ws.ts +118 -0
  59. package/test/ai-mcp.test.ts +61 -0
  60. package/test/auth.test.ts +108 -0
  61. package/test/bench.test.ts +31 -0
  62. package/test/cache.test.ts +36 -0
  63. package/test/cli-create.test.ts +64 -0
  64. package/test/client.test.ts +54 -0
  65. package/test/collection-export.test.ts +52 -0
  66. package/test/compat.test.ts +48 -0
  67. package/test/compress.test.ts +48 -0
  68. package/test/context.test.ts +48 -0
  69. package/test/db.test.ts +62 -0
  70. package/test/docker.test.ts +28 -0
  71. package/test/events.test.ts +62 -0
  72. package/test/file-upload.test.ts +116 -0
  73. package/test/hooks.test.ts +77 -0
  74. package/test/idempotency.test.ts +65 -0
  75. package/test/integration.test.ts +111 -0
  76. package/test/jobs.test.ts +60 -0
  77. package/test/metrics.test.ts +40 -0
  78. package/test/mock.test.ts +33 -0
  79. package/test/multi-tenant.test.ts +80 -0
  80. package/test/observability.test.ts +95 -0
  81. package/test/openapi.test.ts +41 -0
  82. package/test/orm-relations.test.ts +85 -0
  83. package/test/orm-shortcuts.test.ts +52 -0
  84. package/test/rbac.test.ts +85 -0
  85. package/test/router-fastpath.test.ts +42 -0
  86. package/test/router.test.ts +84 -0
  87. package/test/schema.test.ts +49 -0
  88. package/test/seeder-migrator.test.ts +78 -0
  89. package/test/shield.test.ts +55 -0
  90. package/test/shorthand-dx.test.ts +31 -0
  91. package/test/sse.test.ts +43 -0
  92. package/test/static-serve.test.ts +53 -0
  93. package/test/storage.test.ts +31 -0
  94. package/test/studio.test.ts +43 -0
  95. package/test/tx.test.ts +92 -0
  96. package/test/webhook.test.ts +113 -0
  97. package/test/ws.test.ts +54 -0
  98. package/tsconfig.json +19 -0
@@ -0,0 +1,116 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { createApp, createRouter, s } from '../src/index';
3
+
4
+ describe('FireAPI Native Multipart & File Upload Shield (s.file, ctx.files)', () => {
5
+ it('parses multipart/form-data and stores uploaded files via ctx.files.*.store()', async () => {
6
+ const app = createApp({
7
+ storage: { driver: 'memory' },
8
+ });
9
+ const router = createRouter();
10
+
11
+ const UploadSchema = s.object({
12
+ title: s.string().min(2),
13
+ avatar: s.file({
14
+ maxSize: '2MB',
15
+ types: ['image/png', 'image/jpeg'],
16
+ }),
17
+ });
18
+
19
+ router.post('/upload', {
20
+ schema: { body: UploadSchema },
21
+ handler: async (ctx) => {
22
+ const stored = await ctx.files.avatar.store('avatars');
23
+ return ctx.created({
24
+ title: ctx.body.title,
25
+ fileName: ctx.files.avatar.name,
26
+ fileSize: ctx.files.avatar.size,
27
+ url: stored.url,
28
+ });
29
+ },
30
+ });
31
+
32
+ app.use(router);
33
+
34
+ // Create a real FormData
35
+ const formData = new FormData();
36
+ formData.append('title', 'My Profile Photo');
37
+ const fakeFile = new File(['fake-png-binary-content-12345'], 'profile.png', { type: 'image/png' });
38
+ formData.append('avatar', fakeFile);
39
+
40
+ const res = await app.handle(
41
+ new Request('http://localhost:3000/upload', {
42
+ method: 'POST',
43
+ body: formData,
44
+ })
45
+ );
46
+
47
+ expect(res.status).toBe(201);
48
+ const body = await res.json();
49
+ expect(body.title).toBe('My Profile Photo');
50
+ expect(body.fileName).toBe('profile.png');
51
+ expect(body.fileSize).toBeGreaterThan(0);
52
+ expect(body.url).toContain('/uploads/avatars/');
53
+ });
54
+
55
+ it('rejects files exceeding maxSize with 400 validation error', async () => {
56
+ const app = createApp();
57
+ const router = createRouter();
58
+
59
+ router.post('/upload-strict', {
60
+ schema: {
61
+ body: s.object({
62
+ doc: s.file({ maxSize: '10B' }), // Tiny limit: 10 bytes!
63
+ }),
64
+ },
65
+ handler: async (ctx) => ctx.ok({ success: true }),
66
+ });
67
+
68
+ app.use(router);
69
+
70
+ const formData = new FormData();
71
+ const largeFile = new File(['This file is definitely longer than 10 bytes'], 'big.txt', { type: 'text/plain' });
72
+ formData.append('doc', largeFile);
73
+
74
+ const res = await app.handle(
75
+ new Request('http://localhost:3000/upload-strict', {
76
+ method: 'POST',
77
+ body: formData,
78
+ })
79
+ );
80
+
81
+ expect(res.status).toBe(400);
82
+ const body = await res.json();
83
+ expect(body.message).toBe('Validation Error');
84
+ });
85
+
86
+ it('rejects forbidden file types with 400 validation error', async () => {
87
+ const app = createApp();
88
+ const router = createRouter();
89
+
90
+ router.post('/upload-images-only', {
91
+ schema: {
92
+ body: s.object({
93
+ image: s.file({ types: ['image/png', 'image/jpeg'] }),
94
+ }),
95
+ },
96
+ handler: async (ctx) => ctx.ok({ success: true }),
97
+ });
98
+
99
+ app.use(router);
100
+
101
+ const formData = new FormData();
102
+ const exeFile = new File(['malicious-binary'], 'malware.exe', { type: 'application/x-msdownload' });
103
+ formData.append('image', exeFile);
104
+
105
+ const res = await app.handle(
106
+ new Request('http://localhost:3000/upload-images-only', {
107
+ method: 'POST',
108
+ body: formData,
109
+ })
110
+ );
111
+
112
+ expect(res.status).toBe(400);
113
+ const body = await res.json();
114
+ expect(body.message).toBe('Validation Error');
115
+ });
116
+ });
@@ -0,0 +1,77 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { createApp, createRouter } from '../src/index';
3
+
4
+ describe('FireAPI Lifecycle Interceptors & Hooks (app.hook)', () => {
5
+ it('intercepts request early via onRequest hook', async () => {
6
+ const app = createApp();
7
+ const router = createRouter();
8
+
9
+ let hookExecuted = false;
10
+
11
+ // Reject IP if blacklisted
12
+ app.hook('onRequest', async (ctx) => {
13
+ hookExecuted = true;
14
+ if (ctx.req.headers.get('x-blocked-client') === 'true') {
15
+ return ctx.forbidden('Access blocked by security hook');
16
+ }
17
+ });
18
+
19
+ router.get('/data', (ctx) => ctx.ok({ secret: 'safe' }));
20
+ app.use(router);
21
+
22
+ // 1. Blocked request
23
+ const resBlocked = await app.handle(
24
+ new Request('http://localhost:3000/data', {
25
+ headers: { 'x-blocked-client': 'true' },
26
+ })
27
+ );
28
+ expect(resBlocked.status).toBe(403);
29
+ expect(hookExecuted).toBe(true);
30
+
31
+ // 2. Normal request
32
+ const resNormal = await app.handle(new Request('http://localhost:3000/data'));
33
+ expect(resNormal.status).toBe(200);
34
+ });
35
+
36
+ it('modifies response via afterHandle hook', async () => {
37
+ const app = createApp();
38
+ const router = createRouter();
39
+
40
+ app.hook('afterHandle', async (ctx, response) => {
41
+ const headers = new Headers(response.headers);
42
+ headers.set('X-Global-Audit', 'verified');
43
+ return new Response(response.body, {
44
+ status: response.status,
45
+ headers,
46
+ });
47
+ });
48
+
49
+ router.get('/items', (ctx) => ctx.ok(['item1', 'item2']));
50
+ app.use(router);
51
+
52
+ const res = await app.handle(new Request('http://localhost:3000/items'));
53
+ expect(res.status).toBe(200);
54
+ expect(res.headers.get('X-Global-Audit')).toBe('verified');
55
+ });
56
+
57
+ it('captures uncaught errors via onError hook', async () => {
58
+ const app = createApp();
59
+ const router = createRouter();
60
+
61
+ let capturedError: any = null;
62
+
63
+ app.hook('onError', async (err) => {
64
+ capturedError = err;
65
+ });
66
+
67
+ router.get('/crash', () => {
68
+ throw new Error('Database connection explosion');
69
+ });
70
+ app.use(router);
71
+
72
+ const res = await app.handle(new Request('http://localhost:3000/crash'));
73
+ expect(res.status).toBe(500);
74
+ expect(capturedError).not.toBeNull();
75
+ expect(capturedError.message).toBe('Database connection explosion');
76
+ });
77
+ });
@@ -0,0 +1,65 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { createApp, createRouter } from '../src/index';
3
+
4
+ describe('FireAPI Idempotency Shield (X-Idempotency-Key)', () => {
5
+ it('deduplicates requests and returns identical cached response without re-executing handler', async () => {
6
+ const app = createApp({ database: { driver: 'memory' } });
7
+ let executionCount = 0;
8
+
9
+ const orderRouter = createRouter('/orders');
10
+ orderRouter.post('/checkout', {
11
+ idempotent: true,
12
+ handler: async (ctx) => {
13
+ executionCount++;
14
+ const order = await ctx.db.orders.insert({
15
+ total: ctx.body.total,
16
+ orderNumber: `ORD-${Date.now()}-${executionCount}`,
17
+ });
18
+ return ctx.created(order);
19
+ },
20
+ });
21
+
22
+ app.use(orderRouter);
23
+
24
+ const idempotencyKey = 'unique-key-xyz-777';
25
+
26
+ // 1. First Request
27
+ const res1 = await app.handle(
28
+ new Request('http://localhost:3000/orders/checkout', {
29
+ method: 'POST',
30
+ headers: {
31
+ 'Content-Type': 'application/json',
32
+ 'X-Idempotency-Key': idempotencyKey,
33
+ },
34
+ body: JSON.stringify({ total: 100 }),
35
+ })
36
+ );
37
+
38
+ expect(res1.status).toBe(201);
39
+ const data1 = await res1.json();
40
+ expect(executionCount).toBe(1);
41
+ expect(data1.total).toBe(100);
42
+
43
+ // 2. Second Request (Identical X-Idempotency-Key) - Must NOT re-execute handler!
44
+ const res2 = await app.handle(
45
+ new Request('http://localhost:3000/orders/checkout', {
46
+ method: 'POST',
47
+ headers: {
48
+ 'Content-Type': 'application/json',
49
+ 'X-Idempotency-Key': idempotencyKey,
50
+ },
51
+ body: JSON.stringify({ total: 100 }),
52
+ })
53
+ );
54
+
55
+ expect(res2.status).toBe(201);
56
+ const data2 = await res2.json();
57
+ expect(executionCount).toBe(1); // Handler was NOT run again!
58
+ expect(data2.orderNumber).toBe(data1.orderNumber); // Identical response payload!
59
+ expect(res2.headers.get('X-Idempotent-Replay')).toBe('true');
60
+
61
+ // Verify only 1 order exists in database
62
+ const ordersInDb = await app.db.orders.find();
63
+ expect(ordersInDb.length).toBe(1);
64
+ });
65
+ });
@@ -0,0 +1,111 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { createApp, createRouter, s, createClient } from '../src/index';
3
+
4
+ describe('FireAPI End-to-End Integration Suite', () => {
5
+ it('powers a complete production API lifecycle with DB, Security, Validation, and Client', async () => {
6
+ const app = createApp({
7
+ database: { driver: 'memory' },
8
+ security: {
9
+ rateLimit: { max: 100, window: '1m' },
10
+ cors: true,
11
+ },
12
+ docs: '/docs',
13
+ openapiPath: '/openapi.json',
14
+ });
15
+
16
+ const UserSchema = s.object({
17
+ name: s.string().min(2),
18
+ email: s.string().email(),
19
+ role: s.enum(['admin', 'member']).default('member'),
20
+ });
21
+
22
+ const userRouter = createRouter('/users');
23
+
24
+ userRouter.post('/', {
25
+ schema: { body: UserSchema },
26
+ ai: { description: 'Register a new user' },
27
+ handler: async (ctx) => {
28
+ const created = await ctx.db.table('users').insert(ctx.body);
29
+ return ctx.created(created);
30
+ },
31
+ });
32
+
33
+ userRouter.get('/:id', {
34
+ schema: { params: s.object({ id: s.string().min(1) }) },
35
+ cache: { ttl: 30, tags: ['users'] },
36
+ ai: { description: 'Get user by unique ID' },
37
+ handler: async (ctx) => {
38
+ const user = await ctx.db.table('users').findById(ctx.params.id);
39
+ if (!user) return ctx.notFound('User not found');
40
+ return ctx.ok(user);
41
+ },
42
+ });
43
+
44
+ app.use(userRouter);
45
+ app.enableMCP({ name: 'IntegrationMCP' });
46
+
47
+ // 1. Create client bound to app.handle directly
48
+ const client = createClient('http://localhost:3000', {
49
+ fetch: (url, init) => {
50
+ const req = new Request(url, init);
51
+ return app.handle(req);
52
+ },
53
+ });
54
+
55
+ // 2. Test POST /users (Success)
56
+ const postRes = await client.users.post({
57
+ body: { name: 'Yoga Developer', email: 'yoga@fire.dev' },
58
+ });
59
+ expect(postRes.status).toBe(201);
60
+ expect(postRes.data.id).toBeDefined();
61
+ expect(postRes.data.name).toBe('Yoga Developer');
62
+ expect(postRes.data.role).toBe('member');
63
+ expect(postRes.headers.get('x-response-time')).toBeDefined();
64
+ expect(postRes.headers.get('x-content-type-options')).toBe('nosniff');
65
+
66
+ const createdId = postRes.data.id;
67
+
68
+ // 3. Test POST /users (Validation failure)
69
+ const badRes = await client.users.post({
70
+ body: { name: 'Y', email: 'invalid-email' },
71
+ });
72
+ expect(badRes.status).toBe(400);
73
+ expect(badRes.data.error).toBe('Bad Request');
74
+
75
+ // 4. Test GET /users/:id (Success)
76
+ const getRes = await client.users[':id'].get({ params: { id: createdId } });
77
+ expect(getRes.status).toBe(200);
78
+ expect(getRes.data.email).toBe('yoga@fire.dev');
79
+
80
+ // 5. Test OpenAPI Spec generation
81
+ const specReq = new Request('http://localhost:3000/openapi.json');
82
+ const specRes = await app.handle(specReq);
83
+ expect(specRes.status).toBe(200);
84
+ const specJson = await specRes.json();
85
+ expect(specJson.openapi).toBe('3.1.0');
86
+ expect(specJson.paths['/users'].post).toBeDefined();
87
+
88
+ // 6. Test Scalar Interactive Docs HTML
89
+ const docsReq = new Request('http://localhost:3000/docs');
90
+ const docsRes = await app.handle(docsReq);
91
+ expect(docsRes.status).toBe(200);
92
+ expect(docsRes.headers.get('content-type')).toContain('text/html');
93
+ const htmlText = await docsRes.text();
94
+ expect(htmlText).toContain('@scalar/api-reference');
95
+
96
+ // 7. Test AI / MCP Tool execution endpoint
97
+ const mcpReq = new Request('http://localhost:3000/mcp', {
98
+ method: 'POST',
99
+ headers: { 'Content-Type': 'application/json' },
100
+ body: JSON.stringify({
101
+ jsonrpc: '2.0',
102
+ id: '1',
103
+ method: 'tools/list',
104
+ }),
105
+ });
106
+ const mcpRes = await app.handle(mcpReq);
107
+ expect(mcpRes.status).toBe(200);
108
+ const mcpData = await mcpRes.json();
109
+ expect(mcpData.result.tools.length).toBeGreaterThanOrEqual(2);
110
+ });
111
+ });
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { FireQueueManager } from '../src/jobs/queue';
3
+ import { FireCronScheduler } from '../src/jobs/cron';
4
+
5
+ describe('FireAPI Zero-Redis Background Jobs & Cron Scheduler', () => {
6
+ it('dispatches and processes background jobs asynchronously', async () => {
7
+ const queue = new FireQueueManager();
8
+ let processedData: any = null;
9
+
10
+ queue.handle('send-welcome', async (data) => {
11
+ processedData = data;
12
+ });
13
+
14
+ const job = await queue.dispatch('send-welcome', { to: 'yoga@fire.dev', name: 'Yoga' });
15
+ expect(job.id).toBeDefined();
16
+ expect(job.name).toBe('send-welcome');
17
+
18
+ // Wait briefly for in-process worker to consume
19
+ await new Promise((r) => setTimeout(r, 30));
20
+
21
+ expect(processedData).toEqual({ to: 'yoga@fire.dev', name: 'Yoga' });
22
+ expect(job.status).toBe('completed');
23
+ });
24
+
25
+ it('retries failed jobs up to maxAttempts', async () => {
26
+ const queue = new FireQueueManager();
27
+ let attemptCount = 0;
28
+
29
+ queue.handle('unstable-task', async () => {
30
+ attemptCount++;
31
+ if (attemptCount < 2) {
32
+ throw new Error('Temporary glitch');
33
+ }
34
+ });
35
+
36
+ const job = await queue.dispatch('unstable-task', {}, { maxAttempts: 3 });
37
+
38
+ await new Promise((r) => setTimeout(r, 60));
39
+
40
+ expect(attemptCount).toBe(2);
41
+ expect(job.status).toBe('completed');
42
+ });
43
+
44
+ it('executes scheduled cron tasks periodically and supports stopping', async () => {
45
+ const cron = new FireCronScheduler();
46
+ let ticks = 0;
47
+
48
+ const task = cron.schedule('20ms', () => {
49
+ ticks++;
50
+ });
51
+
52
+ await new Promise((r) => setTimeout(r, 55));
53
+ expect(ticks).toBeGreaterThanOrEqual(2);
54
+
55
+ task.stop();
56
+ const recordedTicks = ticks;
57
+ await new Promise((r) => setTimeout(r, 40));
58
+ expect(ticks).toBe(recordedTicks); // stopped, ticks didn't increase
59
+ });
60
+ });
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { MetricsCollector } from '../src/metrics/metrics';
3
+ import { createApp } from '../src/core/app';
4
+
5
+ describe('FireAPI Prometheus Metrics & APM Engine (fire-api/metrics)', () => {
6
+ it('records requests and formats standard Prometheus output', () => {
7
+ const collector = new MetricsCollector();
8
+
9
+ collector.recordRequest('GET', '/api/users', 200, 1.45);
10
+ collector.recordRequest('GET', '/api/users', 200, 2.10);
11
+ collector.recordRequest('POST', '/api/users', 201, 5.20);
12
+ collector.recordRequest('GET', '/api/secret', 403, 0.80);
13
+
14
+ const output = collector.formatPrometheus();
15
+
16
+ expect(output).toContain('# HELP http_requests_total');
17
+ expect(output).toContain('# TYPE http_requests_total counter');
18
+ expect(output).toContain('http_requests_total{method="GET",path="/api/users",status="200"} 2');
19
+ expect(output).toContain('http_requests_total{method="POST",path="/api/users",status="201"} 1');
20
+ expect(output).toContain('http_requests_total{method="GET",path="/api/secret",status="403"} 1');
21
+ expect(output).toContain('http_request_duration_ms_count');
22
+ expect(output).toContain('process_resident_memory_bytes');
23
+ expect(output).toContain('process_uptime_seconds');
24
+ });
25
+
26
+ it('serves Prometheus metrics at /metrics automatically from FireApp', async () => {
27
+ const app = createApp();
28
+
29
+ // Trigger one request to record
30
+ await app.handle(new Request('http://localhost:3000/docs'));
31
+
32
+ const metricsRes = await app.handle(new Request('http://localhost:3000/metrics'));
33
+ expect(metricsRes.status).toBe(200);
34
+ expect(metricsRes.headers.get('Content-Type')).toContain('text/plain');
35
+
36
+ const text = await metricsRes.text();
37
+ expect(text).toContain('http_requests_total');
38
+ expect(text).toContain('method="GET"');
39
+ });
40
+ });
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { s } from '../src/schema/schema';
3
+ import { generateMock } from '../src/schema/mock';
4
+
5
+ describe('FireAPI Schema-Driven Auto-Mocking Engine', () => {
6
+ it('generates schema-compliant mock data that passes schema validation 100%', () => {
7
+ const ProductSchema = s.object({
8
+ id: s.string().min(3),
9
+ title: s.string().min(2),
10
+ email: s.string().email(),
11
+ price: s.number().min(10).max(500),
12
+ isAvailable: s.boolean(),
13
+ category: s.enum(['electronics', 'books', 'fashion']),
14
+ tags: s.array(s.string().min(1)).min(1),
15
+ });
16
+
17
+ const mockData = generateMock(ProductSchema);
18
+
19
+ expect(mockData).toBeDefined();
20
+ expect(typeof mockData.title).toBe('string');
21
+ expect(mockData.email).toContain('@');
22
+ expect(mockData.price).toBeGreaterThanOrEqual(10);
23
+ expect(mockData.price).toBeLessThanOrEqual(500);
24
+ expect(typeof mockData.isAvailable).toBe('boolean');
25
+ expect(['electronics', 'books', 'fashion']).toContain(mockData.category);
26
+ expect(Array.isArray(mockData.tags)).toBe(true);
27
+ expect(mockData.tags.length).toBeGreaterThanOrEqual(1);
28
+
29
+ // Verify it passes validation
30
+ const validation = ProductSchema.validate(mockData);
31
+ expect(validation.success).toBe(true);
32
+ });
33
+ });
@@ -0,0 +1,80 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { createApp, createRouter } from '../src/index';
3
+
4
+ describe('FireAPI Zero-Ceremony Multi-Tenant SaaS Scoping (ctx.tenant)', () => {
5
+ it('automatically isolates database records per tenant using X-Tenant-ID header', async () => {
6
+ const app = createApp({
7
+ database: { driver: 'memory' },
8
+ multiTenant: {
9
+ header: 'X-Tenant-ID',
10
+ autoScopeDB: true,
11
+ },
12
+ });
13
+
14
+ const router = createRouter('/products');
15
+
16
+ router.post('/', async (ctx) => {
17
+ const product = await ctx.db.products.insert({ name: ctx.body.name, price: ctx.body.price });
18
+ return ctx.created(product);
19
+ });
20
+
21
+ router.get('/', async (ctx) => {
22
+ const products = await ctx.db.products.find();
23
+ return ctx.ok({ tenantId: ctx.tenant?.id, products });
24
+ });
25
+
26
+ app.use(router);
27
+
28
+ // 1. Tenant Acme inserts a product
29
+ const resAcmeInsert = await app.handle(
30
+ new Request('http://localhost:3000/products', {
31
+ method: 'POST',
32
+ headers: {
33
+ 'Content-Type': 'application/json',
34
+ 'X-Tenant-ID': 'org_acme',
35
+ },
36
+ body: JSON.stringify({ name: 'Acme Anvil', price: 99 }),
37
+ })
38
+ );
39
+ expect(resAcmeInsert.status).toBe(201);
40
+
41
+ // 2. Tenant Stark inserts a product
42
+ const resStarkInsert = await app.handle(
43
+ new Request('http://localhost:3000/products', {
44
+ method: 'POST',
45
+ headers: {
46
+ 'Content-Type': 'application/json',
47
+ 'X-Tenant-ID': 'org_stark',
48
+ },
49
+ body: JSON.stringify({ name: 'Arc Reactor', price: 99999 }),
50
+ })
51
+ );
52
+ expect(resStarkInsert.status).toBe(201);
53
+
54
+ // 3. Acme queries products -> MUST ONLY see Acme's products!
55
+ const resAcmeGet = await app.handle(
56
+ new Request('http://localhost:3000/products', {
57
+ headers: { 'X-Tenant-ID': 'org_acme' },
58
+ })
59
+ );
60
+ expect(resAcmeGet.status).toBe(200);
61
+ const acmeData = await resAcmeGet.json();
62
+ expect(acmeData.tenantId).toBe('org_acme');
63
+ expect(acmeData.products.length).toBe(1);
64
+ expect(acmeData.products[0].name).toBe('Acme Anvil');
65
+ expect(acmeData.products[0].tenantId).toBe('org_acme');
66
+
67
+ // 4. Stark queries products -> MUST ONLY see Stark's products!
68
+ const resStarkGet = await app.handle(
69
+ new Request('http://localhost:3000/products', {
70
+ headers: { 'X-Tenant-ID': 'org_stark' },
71
+ })
72
+ );
73
+ expect(resStarkGet.status).toBe(200);
74
+ const starkData = await resStarkGet.json();
75
+ expect(starkData.tenantId).toBe('org_stark');
76
+ expect(starkData.products.length).toBe(1);
77
+ expect(starkData.products[0].name).toBe('Arc Reactor');
78
+ expect(starkData.products[0].tenantId).toBe('org_stark');
79
+ });
80
+ });
@@ -0,0 +1,95 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { createApp, createRouter } from '../src/index';
3
+
4
+ describe('FireAPI Cloud-Native Observability, Health & Structured Logging', () => {
5
+ it('automatically sets and propagates X-Request-ID across context and headers', async () => {
6
+ const app = createApp();
7
+ const router = createRouter();
8
+
9
+ let capturedReqId = '';
10
+
11
+ router.get('/trace', (ctx) => {
12
+ capturedReqId = ctx.requestId;
13
+ return ctx.ok({ traceId: ctx.requestId });
14
+ });
15
+
16
+ app.use(router);
17
+
18
+ // 1. Client provides custom X-Request-ID
19
+ const res1 = await app.handle(
20
+ new Request('http://localhost:3000/trace', {
21
+ headers: { 'X-Request-ID': 'custom-req-123' },
22
+ })
23
+ );
24
+
25
+ expect(res1.status).toBe(200);
26
+ expect(res1.headers.get('X-Request-ID')).toBe('custom-req-123');
27
+ expect(capturedReqId).toBe('custom-req-123');
28
+
29
+ // 2. Client does not provide header -> auto-generated unique ID
30
+ const res2 = await app.handle(new Request('http://localhost:3000/trace'));
31
+ expect(res2.status).toBe(200);
32
+ const generatedId = res2.headers.get('X-Request-ID');
33
+ expect(generatedId).not.toBeNull();
34
+ expect(generatedId?.startsWith('req_')).toBe(true);
35
+ expect(capturedReqId).toBe(generatedId!);
36
+ });
37
+
38
+ it('provides structured JSON logging via ctx.log with automatic reqId context', async () => {
39
+ const app = createApp();
40
+ const router = createRouter();
41
+
42
+ const capturedLogs: any[] = [];
43
+
44
+ router.get('/log-test', (ctx) => {
45
+ ctx.log.setSink((record: any) => capturedLogs.push(record));
46
+ ctx.log.info('Processing order', { amount: 250, currency: 'USD' });
47
+ ctx.log.warn('Inventory low', { sku: 'SKU-001' });
48
+ return ctx.ok({ logged: true });
49
+ });
50
+
51
+ app.use(router);
52
+
53
+ const res = await app.handle(
54
+ new Request('http://localhost:3000/log-test', {
55
+ headers: { 'X-Request-ID': 'req-logger-99' },
56
+ })
57
+ );
58
+
59
+ expect(res.status).toBe(200);
60
+ expect(capturedLogs.length).toBe(2);
61
+
62
+ expect(capturedLogs[0].level).toBe('info');
63
+ expect(capturedLogs[0].msg).toBe('Processing order');
64
+ expect(capturedLogs[0].reqId).toBe('req-logger-99');
65
+ expect(capturedLogs[0].amount).toBe(250);
66
+
67
+ expect(capturedLogs[1].level).toBe('warn');
68
+ expect(capturedLogs[1].msg).toBe('Inventory low');
69
+ });
70
+
71
+ it('serves liveness (/healthz) and readiness (/readyz) probes automatically', async () => {
72
+ const app = createApp({
73
+ database: { driver: 'memory' },
74
+ health: {
75
+ livenessPath: '/healthz',
76
+ readinessPath: '/readyz',
77
+ checkDatabase: true,
78
+ },
79
+ });
80
+
81
+ // 1. Test /healthz
82
+ const livenessRes = await app.handle(new Request('http://localhost:3000/healthz'));
83
+ expect(livenessRes.status).toBe(200);
84
+ const livenessBody = await livenessRes.json();
85
+ expect(livenessBody.status).toBe('ok');
86
+ expect(typeof livenessBody.timestamp).toBe('number');
87
+
88
+ // 2. Test /readyz
89
+ const readinessRes = await app.handle(new Request('http://localhost:3000/readyz'));
90
+ expect(readinessRes.status).toBe(200);
91
+ const readinessBody = await readinessRes.json();
92
+ expect(readinessBody.status).toBe('ok');
93
+ expect(readinessBody.checks.database).toBe('ok');
94
+ });
95
+ });