@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.
- package/README.md +276 -2
- package/bin/fire.js +139 -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,41 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { Router } from '../src/core/router';
|
|
3
|
+
import { s } from '../src/schema/schema';
|
|
4
|
+
import { generateOpenAPISpec } from '../src/schema/openapi';
|
|
5
|
+
import { renderScalarDocsHTML } from '../src/docs/scalar-ui';
|
|
6
|
+
|
|
7
|
+
describe('FireAPI OpenAPI 3.1 & Scalar Documentation Engine', () => {
|
|
8
|
+
it('generates compliant OpenAPI 3.1 specification from routes and schemas', () => {
|
|
9
|
+
const router = new Router();
|
|
10
|
+
|
|
11
|
+
router.post('/users', {
|
|
12
|
+
ai: { description: 'Create user' },
|
|
13
|
+
schema: {
|
|
14
|
+
body: s.object({
|
|
15
|
+
name: s.string().min(2),
|
|
16
|
+
email: s.string().email(),
|
|
17
|
+
}),
|
|
18
|
+
},
|
|
19
|
+
handler: (ctx) => ctx.created({}),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const spec = generateOpenAPISpec(router, {
|
|
23
|
+
title: 'Test FireAPI',
|
|
24
|
+
version: '1.0.0',
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
expect(spec.openapi).toBe('3.1.0');
|
|
28
|
+
expect(spec.info.title).toBe('Test FireAPI');
|
|
29
|
+
expect(spec.paths['/users']).toBeDefined();
|
|
30
|
+
expect(spec.paths['/users'].post).toBeDefined();
|
|
31
|
+
expect(spec.paths['/users'].post.requestBody.content['application/json'].schema.type).toBe('object');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('renders modern Scalar interactive documentation HTML', () => {
|
|
35
|
+
const html = renderScalarDocsHTML('/openapi.json', 'My Fire API Docs');
|
|
36
|
+
expect(html).toContain('<!DOCTYPE html>');
|
|
37
|
+
expect(html).toContain('@scalar/api-reference');
|
|
38
|
+
expect(html).toContain('/openapi.json');
|
|
39
|
+
expect(html).toContain('My Fire API Docs');
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { createApp, createRouter } from '../src/index';
|
|
3
|
+
|
|
4
|
+
describe('FireAPI Ultra-Simple Relational Proxy ORM (.include())', () => {
|
|
5
|
+
it('eager-loads one-to-many relations using ctx.db.users.include("posts").findById(id)', async () => {
|
|
6
|
+
const app = createApp({ database: { driver: 'memory' } });
|
|
7
|
+
const router = createRouter();
|
|
8
|
+
|
|
9
|
+
// 1. Seed data
|
|
10
|
+
const user = await app.db.users.insert({ id: 'u100', name: 'Devin', role: 'admin' });
|
|
11
|
+
await app.db.posts.insert([
|
|
12
|
+
{ id: 'p1', userId: 'u100', title: 'First Post', published: true },
|
|
13
|
+
{ id: 'p2', userId: 'u100', title: 'Second Post', published: false },
|
|
14
|
+
{ id: 'p3', userId: 'other_user', title: 'Unrelated Post', published: true },
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
router.get('/users/:id', async (ctx) => {
|
|
18
|
+
const result = await ctx.db.users.include('posts').findById(ctx.params.id);
|
|
19
|
+
if (!result) return ctx.notFound('User not found');
|
|
20
|
+
return ctx.ok(result);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
app.use(router);
|
|
24
|
+
|
|
25
|
+
const res = await app.handle(
|
|
26
|
+
new Request('http://localhost:3000/users/u100', { method: 'GET' })
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
expect(res.status).toBe(200);
|
|
30
|
+
const data = await res.json();
|
|
31
|
+
expect(data.id).toBe('u100');
|
|
32
|
+
expect(data.name).toBe('Devin');
|
|
33
|
+
expect(Array.isArray(data.posts)).toBe(true);
|
|
34
|
+
expect(data.posts.length).toBe(2);
|
|
35
|
+
expect(data.posts[0].title).toBe('First Post');
|
|
36
|
+
expect(data.posts[1].title).toBe('Second Post');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('eager-loads many-to-one parent relations using ctx.db.posts.include("user").findById(id)', async () => {
|
|
40
|
+
const app = createApp({ database: { driver: 'memory' } });
|
|
41
|
+
|
|
42
|
+
await app.db.users.insert({ id: 'u200', name: 'Grace Hopper' });
|
|
43
|
+
await app.db.posts.insert({ id: 'p50', userId: 'u200', title: 'Compilers in Practice' });
|
|
44
|
+
|
|
45
|
+
const post = await app.db.posts.include('user').findById('p50');
|
|
46
|
+
|
|
47
|
+
expect(post).not.toBeNull();
|
|
48
|
+
expect(post.title).toBe('Compilers in Practice');
|
|
49
|
+
expect(post.user).toBeDefined();
|
|
50
|
+
expect(post.user.name).toBe('Grace Hopper');
|
|
51
|
+
expect(post.user.id).toBe('u200');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('eager-loads multiple relations across array results using .include(["posts", "profile"]).find()', async () => {
|
|
55
|
+
const app = createApp({ database: { driver: 'memory' } });
|
|
56
|
+
|
|
57
|
+
await app.db.users.insert([
|
|
58
|
+
{ id: 'u1', name: 'User One' },
|
|
59
|
+
{ id: 'u2', name: 'User Two' },
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
await app.db.posts.insert([
|
|
63
|
+
{ id: 'p1', userId: 'u1', title: 'U1 Post' },
|
|
64
|
+
{ id: 'p2', userId: 'u2', title: 'U2 Post' },
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
await app.db.profiles.insert([
|
|
68
|
+
{ id: 'prof1', userId: 'u1', bio: 'Bio 1' },
|
|
69
|
+
{ id: 'prof2', userId: 'u2', bio: 'Bio 2' },
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
const users = await app.db.users.include(['posts', 'profile']).find();
|
|
73
|
+
|
|
74
|
+
expect(users.length).toBe(2);
|
|
75
|
+
expect(users[0].posts.length).toBe(1);
|
|
76
|
+
expect(users[0].posts[0].title).toBe('U1 Post');
|
|
77
|
+
expect(users[0].profile).toBeDefined();
|
|
78
|
+
expect(users[0].profile.bio).toBe('Bio 1');
|
|
79
|
+
|
|
80
|
+
expect(users[1].posts.length).toBe(1);
|
|
81
|
+
expect(users[1].posts[0].title).toBe('U2 Post');
|
|
82
|
+
expect(users[1].profile).toBeDefined();
|
|
83
|
+
expect(users[1].profile.bio).toBe('Bio 2');
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { createApp } from '../src/index';
|
|
3
|
+
|
|
4
|
+
describe('FireAPI Ultra-Simple Proxy ORM Shortcuts', () => {
|
|
5
|
+
it('supports direct table property access: ctx.db.products.delete(id)', async () => {
|
|
6
|
+
const app = createApp({ database: { driver: 'memory' } });
|
|
7
|
+
|
|
8
|
+
// 1. Insert via shortcut: ctx.db.products.insert(...)
|
|
9
|
+
const product = await app.db.products.insert({
|
|
10
|
+
title: 'Gaming Mouse',
|
|
11
|
+
price: 50,
|
|
12
|
+
category: 'hardware',
|
|
13
|
+
});
|
|
14
|
+
expect(product.id).toBeDefined();
|
|
15
|
+
expect(product.title).toBe('Gaming Mouse');
|
|
16
|
+
|
|
17
|
+
// 2. Find by ID via shortcut: ctx.db.products.findById(id)
|
|
18
|
+
const found = await app.db.products.findById(product.id);
|
|
19
|
+
expect(found?.title).toBe('Gaming Mouse');
|
|
20
|
+
|
|
21
|
+
// 3. Find with object filter: ctx.db.products.find({ category: 'hardware' })
|
|
22
|
+
const hardwares = await app.db.products.find({ category: 'hardware' });
|
|
23
|
+
expect(hardwares.length).toBe(1);
|
|
24
|
+
|
|
25
|
+
// 4. Update by ID via shortcut: ctx.db.products.update(id, data)
|
|
26
|
+
const updated = await app.db.products.update(product.id, { price: 45 });
|
|
27
|
+
expect(updated.price).toBe(45);
|
|
28
|
+
|
|
29
|
+
// 5. Delete by ID via shortcut: ctx.db.products.delete(id)
|
|
30
|
+
const deleteResult = await app.db.products.delete(product.id);
|
|
31
|
+
expect(deleteResult.count).toBe(1);
|
|
32
|
+
|
|
33
|
+
// Verify deleted
|
|
34
|
+
const remaining = await app.db.products.findById(product.id);
|
|
35
|
+
expect(remaining).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('supports delete by filter object: ctx.db.products.delete({ category: "old" })', async () => {
|
|
39
|
+
const app = createApp({ database: { driver: 'memory' } });
|
|
40
|
+
|
|
41
|
+
await app.db.items.insert({ title: 'Item 1', status: 'archived' });
|
|
42
|
+
await app.db.items.insert({ title: 'Item 2', status: 'archived' });
|
|
43
|
+
await app.db.items.insert({ title: 'Item 3', status: 'active' });
|
|
44
|
+
|
|
45
|
+
const del = await app.db.items.delete({ status: 'archived' });
|
|
46
|
+
expect(del.count).toBe(2);
|
|
47
|
+
|
|
48
|
+
const activeList = await app.db.items.find();
|
|
49
|
+
expect(activeList.length).toBe(1);
|
|
50
|
+
expect(activeList[0].status).toBe('active');
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { createApp, createRouter } from '../src/index';
|
|
3
|
+
|
|
4
|
+
describe('FireAPI Granular RBAC & Policy Gates', () => {
|
|
5
|
+
it('enforces role requirements (roles: ["admin"]) and blocks unauthorized roles with 403', async () => {
|
|
6
|
+
const app = createApp();
|
|
7
|
+
const router = createRouter('/admin');
|
|
8
|
+
|
|
9
|
+
// Attach user to context for simulation
|
|
10
|
+
const setRole = (role: string) => async (ctx: any, next: any) => {
|
|
11
|
+
ctx.user = { id: 'u1', role };
|
|
12
|
+
return next();
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
router.get('/dashboard', {
|
|
16
|
+
roles: ['admin', 'superadmin'],
|
|
17
|
+
handler: (ctx) => ctx.ok({ secret: 'admin-portal' }),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
app.use(router);
|
|
21
|
+
|
|
22
|
+
// 1. User with 'member' role -> 403 Forbidden
|
|
23
|
+
const memberApp = createApp();
|
|
24
|
+
memberApp.use(setRole('member'));
|
|
25
|
+
memberApp.use(router);
|
|
26
|
+
|
|
27
|
+
const forbiddenRes = await memberApp.handle(new Request('http://localhost:3000/admin/dashboard'));
|
|
28
|
+
expect(forbiddenRes.status).toBe(403);
|
|
29
|
+
const errData = await forbiddenRes.json();
|
|
30
|
+
expect(errData.error).toBe('Forbidden');
|
|
31
|
+
|
|
32
|
+
// 2. User with 'admin' role -> 200 OK
|
|
33
|
+
const adminApp = createApp();
|
|
34
|
+
adminApp.use(setRole('admin'));
|
|
35
|
+
adminApp.use(router);
|
|
36
|
+
|
|
37
|
+
const okRes = await adminApp.handle(new Request('http://localhost:3000/admin/dashboard'));
|
|
38
|
+
expect(okRes.status).toBe(200);
|
|
39
|
+
expect((await okRes.json()).secret).toBe('admin-portal');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('evaluates dynamic resource policy gates (can: async (user, ctx) => boolean)', async () => {
|
|
43
|
+
const app = createApp({ database: { driver: 'memory' } });
|
|
44
|
+
await app.db.table('articles').insert({ id: 'art_1', authorId: 'author_123', title: 'Post 1' });
|
|
45
|
+
|
|
46
|
+
const router = createRouter('/articles');
|
|
47
|
+
|
|
48
|
+
router.delete('/:id', {
|
|
49
|
+
can: async (user, ctx) => {
|
|
50
|
+
const article = await ctx.db.table('articles').findById(ctx.params.id);
|
|
51
|
+
return user?.id === article?.authorId || user?.role === 'admin';
|
|
52
|
+
},
|
|
53
|
+
handler: async (ctx) => {
|
|
54
|
+
await ctx.db.table('articles').where('id', ctx.params.id).delete();
|
|
55
|
+
return ctx.ok({ deleted: true });
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
app.use(router);
|
|
60
|
+
|
|
61
|
+
// 1. Other user trying to delete -> 403 Forbidden
|
|
62
|
+
const unauthApp = createApp();
|
|
63
|
+
unauthApp.db = app.db;
|
|
64
|
+
unauthApp.use(async (ctx, next) => {
|
|
65
|
+
ctx.user = { id: 'other_user', role: 'member' };
|
|
66
|
+
return next();
|
|
67
|
+
});
|
|
68
|
+
unauthApp.use(router);
|
|
69
|
+
|
|
70
|
+
const failRes = await unauthApp.handle(new Request('http://localhost:3000/articles/art_1', { method: 'DELETE' }));
|
|
71
|
+
expect(failRes.status).toBe(403);
|
|
72
|
+
|
|
73
|
+
// 2. Author user deleting own article -> 200 OK
|
|
74
|
+
const authorApp = createApp();
|
|
75
|
+
authorApp.db = app.db;
|
|
76
|
+
authorApp.use(async (ctx, next) => {
|
|
77
|
+
ctx.user = { id: 'author_123', role: 'member' };
|
|
78
|
+
return next();
|
|
79
|
+
});
|
|
80
|
+
authorApp.use(router);
|
|
81
|
+
|
|
82
|
+
const successRes = await authorApp.handle(new Request('http://localhost:3000/articles/art_1', { method: 'DELETE' }));
|
|
83
|
+
expect(successRes.status).toBe(200);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { Router } from '../src/core/router';
|
|
3
|
+
|
|
4
|
+
describe('FireAPI JIT Route Fast-Path Cache', () => {
|
|
5
|
+
it('hits fast-path cache for identical and variable parameter lookups', () => {
|
|
6
|
+
const router = new Router();
|
|
7
|
+
router.get('/products/:id', {
|
|
8
|
+
handler: (ctx) => ctx.ok({ id: ctx.params.id }),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
// 1st lookup - Misses cache, populates fast-path
|
|
12
|
+
const match1 = router.match('GET', '/products/100');
|
|
13
|
+
expect(match1).not.toBeNull();
|
|
14
|
+
expect(match1?.params.id).toBe('100');
|
|
15
|
+
|
|
16
|
+
// 2nd lookup on same URL - Hits fast-path cache
|
|
17
|
+
const match2 = router.match('GET', '/products/100');
|
|
18
|
+
expect(match2).not.toBeNull();
|
|
19
|
+
expect(match2?.params.id).toBe('100');
|
|
20
|
+
|
|
21
|
+
// 3rd lookup with different param - Correctly extracts new param
|
|
22
|
+
const match3 = router.match('GET', '/products/200');
|
|
23
|
+
expect(match3).not.toBeNull();
|
|
24
|
+
expect(match3?.params.id).toBe('200');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('demonstrates extreme sub-microsecond lookup speed across 10,000 iterations', () => {
|
|
28
|
+
const router = new Router();
|
|
29
|
+
router.get('/api/v1/users/:userId/orders/:orderId', {
|
|
30
|
+
handler: () => 'ok',
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const start = performance.now();
|
|
34
|
+
for (let i = 0; i < 10000; i++) {
|
|
35
|
+
router.match('GET', '/api/v1/users/42/orders/999');
|
|
36
|
+
}
|
|
37
|
+
const duration = performance.now() - start;
|
|
38
|
+
|
|
39
|
+
// 10,000 matches should complete in under 20ms (averaging < 0.002ms per match!)
|
|
40
|
+
expect(duration).toBeLessThan(20);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { Router } from '../src/core/router';
|
|
3
|
+
|
|
4
|
+
describe('FireAPI Core Router', () => {
|
|
5
|
+
it('registers and matches static routes', () => {
|
|
6
|
+
const router = new Router();
|
|
7
|
+
router.get('/health', {
|
|
8
|
+
handler: (ctx) => ctx.json({ status: 'ok' }),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const match = router.match('GET', '/health');
|
|
12
|
+
expect(match).not.toBeNull();
|
|
13
|
+
expect(match?.params).toEqual({});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('matches routes with dynamic path parameters', () => {
|
|
17
|
+
const router = new Router();
|
|
18
|
+
router.get('/users/:id', {
|
|
19
|
+
handler: (ctx) => ctx.json({ id: ctx.params.id }),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const match = router.match('GET', '/users/usr_123');
|
|
23
|
+
expect(match).not.toBeNull();
|
|
24
|
+
expect(match?.params).toEqual({ id: 'usr_123' });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('matches nested routes with multiple parameters', () => {
|
|
28
|
+
const router = new Router();
|
|
29
|
+
router.get('/orgs/:orgId/members/:memberId', {
|
|
30
|
+
handler: (ctx) => ctx.json({}),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const match = router.match('GET', '/orgs/google/members/42');
|
|
34
|
+
expect(match).not.toBeNull();
|
|
35
|
+
expect(match?.params).toEqual({ orgId: 'google', memberId: '42' });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('handles wildcards (*)', () => {
|
|
39
|
+
const router = new Router();
|
|
40
|
+
router.get('/assets/*', {
|
|
41
|
+
handler: (ctx) => ctx.text('asset'),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const match = router.match('GET', '/assets/css/style.css');
|
|
45
|
+
expect(match).not.toBeNull();
|
|
46
|
+
expect(match?.params['*']).toBe('css/style.css');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('differentiates HTTP methods', () => {
|
|
50
|
+
const router = new Router();
|
|
51
|
+
router.get('/items', { handler: () => 'get' });
|
|
52
|
+
router.post('/items', { handler: () => 'post' });
|
|
53
|
+
|
|
54
|
+
const getMatch = router.match('GET', '/items');
|
|
55
|
+
const postMatch = router.match('POST', '/items');
|
|
56
|
+
const putMatch = router.match('PUT', '/items');
|
|
57
|
+
|
|
58
|
+
expect(getMatch).not.toBeNull();
|
|
59
|
+
expect(postMatch).not.toBeNull();
|
|
60
|
+
expect(putMatch).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('supports prefix-based sub-router mounting', () => {
|
|
64
|
+
const rootRouter = new Router();
|
|
65
|
+
const apiRouter = new Router();
|
|
66
|
+
const userRouter = new Router();
|
|
67
|
+
|
|
68
|
+
userRouter.get('/:id', { handler: () => 'user' });
|
|
69
|
+
apiRouter.use('/users', userRouter);
|
|
70
|
+
rootRouter.use('/api/v1', apiRouter);
|
|
71
|
+
|
|
72
|
+
const match = rootRouter.match('GET', '/api/v1/users/777');
|
|
73
|
+
expect(match).not.toBeNull();
|
|
74
|
+
expect(match?.params).toEqual({ id: '777' });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('normalizes trailing slashes properly', () => {
|
|
78
|
+
const router = new Router();
|
|
79
|
+
router.get('/dashboard', { handler: () => 'dash' });
|
|
80
|
+
|
|
81
|
+
expect(router.match('GET', '/dashboard/')).not.toBeNull();
|
|
82
|
+
expect(router.match('GET', '/dashboard')).not.toBeNull();
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { s, Infer } from '../src/schema/schema';
|
|
3
|
+
import { validate } from '../src/schema/validator';
|
|
4
|
+
|
|
5
|
+
describe('FireAPI FireSchema Validation Engine', () => {
|
|
6
|
+
it('validates string constraints (min, max, email)', () => {
|
|
7
|
+
const emailSchema = s.string().email();
|
|
8
|
+
expect(validate(emailSchema, 'user@example.com').success).toBe(true);
|
|
9
|
+
expect(validate(emailSchema, 'invalid-email').success).toBe(false);
|
|
10
|
+
|
|
11
|
+
const lengthSchema = s.string().min(3).max(10);
|
|
12
|
+
expect(validate(lengthSchema, 'ab').success).toBe(false);
|
|
13
|
+
expect(validate(lengthSchema, 'valid').success).toBe(true);
|
|
14
|
+
expect(validate(lengthSchema, 'too-long-string-here').success).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('validates numbers with int and ranges', () => {
|
|
18
|
+
const ageSchema = s.number().int().min(18).max(120);
|
|
19
|
+
expect(validate(ageSchema, 25).success).toBe(true);
|
|
20
|
+
expect(validate(ageSchema, 17).success).toBe(false);
|
|
21
|
+
expect(validate(ageSchema, 25.5).success).toBe(false);
|
|
22
|
+
expect(validate(ageSchema, '25').success).toBe(false); // strict type
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('validates nested objects with required and optional fields', () => {
|
|
26
|
+
const userSchema = s.object({
|
|
27
|
+
name: s.string().min(2),
|
|
28
|
+
age: s.number().optional(),
|
|
29
|
+
role: s.enum(['admin', 'user']).default('user'),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
type User = Infer<typeof userSchema>;
|
|
33
|
+
|
|
34
|
+
const validResult = validate(userSchema, { name: 'Alice' });
|
|
35
|
+
expect(validResult.success).toBe(true);
|
|
36
|
+
expect(validResult.data?.role).toBe('user'); // default value applied
|
|
37
|
+
|
|
38
|
+
const invalidResult = validate(userSchema, { name: 'A' });
|
|
39
|
+
expect(invalidResult.success).toBe(false);
|
|
40
|
+
expect(invalidResult.errors?.[0].field).toBe('name');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('validates arrays of schemas', () => {
|
|
44
|
+
const tagsSchema = s.array(s.string().min(1)).min(1);
|
|
45
|
+
expect(validate(tagsSchema, ['tech', 'api']).success).toBe(true);
|
|
46
|
+
expect(validate(tagsSchema, []).success).toBe(false); // min 1
|
|
47
|
+
expect(validate(tagsSchema, ['tech', '']).success).toBe(false); // inner item invalid
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { createApp, s, FireMigrator, Migration } from '../src/index';
|
|
3
|
+
|
|
4
|
+
describe('FireAPI Schema Auto-Seeder & Migration Engine', () => {
|
|
5
|
+
it('seeds database with schema-compliant mock data via app.db.seed()', async () => {
|
|
6
|
+
const app = createApp({ database: { driver: 'memory' } });
|
|
7
|
+
|
|
8
|
+
const BookSchema = s.object({
|
|
9
|
+
title: s.string().min(5),
|
|
10
|
+
author: s.string().min(3),
|
|
11
|
+
price: s.number().min(10).max(500),
|
|
12
|
+
inStock: s.boolean(),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// Seed 10 books
|
|
16
|
+
const seeded = await app.db.seed('books', BookSchema, 10);
|
|
17
|
+
expect(seeded.length).toBe(10);
|
|
18
|
+
|
|
19
|
+
// Verify persisted rows in database
|
|
20
|
+
const booksInDb = await app.db.books.find();
|
|
21
|
+
expect(booksInDb.length).toBe(10);
|
|
22
|
+
expect(booksInDb[0].title.length).toBeGreaterThanOrEqual(5);
|
|
23
|
+
expect(typeof booksInDb[0].price).toBe('number');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('runs migrations up and rolls back cleanly with FireMigrator', async () => {
|
|
27
|
+
const app = createApp({ database: { driver: 'memory' } });
|
|
28
|
+
const migrator = new FireMigrator(app.db);
|
|
29
|
+
|
|
30
|
+
const migrations: Migration[] = [
|
|
31
|
+
{
|
|
32
|
+
name: '001_create_customers',
|
|
33
|
+
up: async (db) => {
|
|
34
|
+
await db.customers.insert({ id: 'c1', name: 'Initial Customer' });
|
|
35
|
+
},
|
|
36
|
+
down: async (db) => {
|
|
37
|
+
await db.customers.delete('c1');
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: '002_create_orders',
|
|
42
|
+
up: async (db) => {
|
|
43
|
+
await db.orders.insert({ id: 'o1', customerId: 'c1', total: 100 });
|
|
44
|
+
},
|
|
45
|
+
down: async (db) => {
|
|
46
|
+
await db.orders.delete('o1');
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
// 1. Check initial status -> both pending
|
|
52
|
+
const initialStatus = await migrator.status(migrations);
|
|
53
|
+
expect(initialStatus.every((m) => m.status === 'pending')).toBe(true);
|
|
54
|
+
|
|
55
|
+
// 2. Run migrations UP
|
|
56
|
+
const upResult = await migrator.up(migrations);
|
|
57
|
+
expect(upResult.applied.length).toBe(2);
|
|
58
|
+
expect(upResult.applied).toEqual(['001_create_customers', '002_create_orders']);
|
|
59
|
+
|
|
60
|
+
// Verify records created
|
|
61
|
+
const customer = await app.db.customers.findById('c1');
|
|
62
|
+
const order = await app.db.orders.findById('o1');
|
|
63
|
+
expect(customer).not.toBeNull();
|
|
64
|
+
expect(order).not.toBeNull();
|
|
65
|
+
|
|
66
|
+
// Verify status -> both applied
|
|
67
|
+
const appliedStatus = await migrator.status(migrations);
|
|
68
|
+
expect(appliedStatus.every((m) => m.status === 'applied')).toBe(true);
|
|
69
|
+
|
|
70
|
+
// 3. Rollback (DOWN)
|
|
71
|
+
const downResult = await migrator.down(migrations);
|
|
72
|
+
expect(downResult.rolledBack.length).toBe(2);
|
|
73
|
+
|
|
74
|
+
// Verify rolled back
|
|
75
|
+
const customerAfter = await app.db.customers.findById('c1');
|
|
76
|
+
expect(customerAfter).toBeNull();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { applySecurityHeaders } from '../src/shield/headers';
|
|
3
|
+
import { handleCORS } from '../src/shield/cors';
|
|
4
|
+
import { RateLimiter } from '../src/shield/rate-limiter';
|
|
5
|
+
import { sanitizePayload } from '../src/shield/sanitizer';
|
|
6
|
+
|
|
7
|
+
describe('FireAPI FireShield Security Engine', () => {
|
|
8
|
+
it('applies standard security headers to responses', () => {
|
|
9
|
+
const baseResponse = new Response(JSON.stringify({ ok: true }));
|
|
10
|
+
const securedResponse = applySecurityHeaders(baseResponse);
|
|
11
|
+
|
|
12
|
+
expect(securedResponse.headers.get('x-content-type-options')).toBe('nosniff');
|
|
13
|
+
expect(securedResponse.headers.get('x-frame-options')).toBe('SAMEORIGIN');
|
|
14
|
+
expect(securedResponse.headers.get('x-xss-protection')).toBe('0');
|
|
15
|
+
expect(securedResponse.headers.get('strict-transport-security')).toContain('max-age=');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('handles CORS preflight and standard requests', () => {
|
|
19
|
+
const preflightReq = new Request('http://localhost:3000/api', {
|
|
20
|
+
method: 'OPTIONS',
|
|
21
|
+
headers: {
|
|
22
|
+
origin: 'https://app.client.com',
|
|
23
|
+
'access-control-request-method': 'POST',
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const preflightRes = handleCORS(preflightReq, { origin: '*' });
|
|
28
|
+
expect(preflightRes).not.toBeNull();
|
|
29
|
+
expect(preflightRes?.status).toBe(204);
|
|
30
|
+
expect(preflightRes?.headers.get('access-control-allow-origin')).toBe('*');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('enforces sliding-window rate limiting and rejects exceeding traffic', () => {
|
|
34
|
+
const limiter = new RateLimiter({ max: 3, windowMs: 1000 });
|
|
35
|
+
const ip = '192.168.1.100';
|
|
36
|
+
|
|
37
|
+
expect(limiter.check(ip).allowed).toBe(true);
|
|
38
|
+
expect(limiter.check(ip).allowed).toBe(true);
|
|
39
|
+
expect(limiter.check(ip).allowed).toBe(true);
|
|
40
|
+
|
|
41
|
+
// 4th request exceeds max 3
|
|
42
|
+
const fourth = limiter.check(ip);
|
|
43
|
+
expect(fourth.allowed).toBe(false);
|
|
44
|
+
expect(fourth.retryAfter).toBeGreaterThan(0);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('detects and strips/rejects prototype pollution attempts', () => {
|
|
48
|
+
const maliciousJson = JSON.parse('{"name":"admin","__proto__":{"isAdmin":true}}');
|
|
49
|
+
const sanitized = sanitizePayload(maliciousJson);
|
|
50
|
+
|
|
51
|
+
expect(sanitized.name).toBe('admin');
|
|
52
|
+
expect((Object.prototype as any).isAdmin).toBeUndefined();
|
|
53
|
+
expect((sanitized as any).__proto__.isAdmin).toBeUndefined();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { createApp } from '../src/index';
|
|
3
|
+
|
|
4
|
+
describe('FireAPI Ultra-Simple Shorthand DX', () => {
|
|
5
|
+
it('allows direct handler function without options wrapper', async () => {
|
|
6
|
+
const app = createApp();
|
|
7
|
+
|
|
8
|
+
// 1. Return raw string
|
|
9
|
+
app.get('/hello', () => 'Hello FireAPI!');
|
|
10
|
+
|
|
11
|
+
// 2. Return raw object (auto 200 JSON)
|
|
12
|
+
app.get('/data', () => ({ status: 'success', count: 42 }));
|
|
13
|
+
|
|
14
|
+
// 3. Return from ctx.db directly
|
|
15
|
+
app.get('/users', async (ctx) => {
|
|
16
|
+
return [{ id: '1', name: 'Yoga' }];
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const res1 = await app.handle(new Request('http://localhost:3000/hello'));
|
|
20
|
+
expect(res1.status).toBe(200);
|
|
21
|
+
expect(await res1.text()).toBe('Hello FireAPI!');
|
|
22
|
+
|
|
23
|
+
const res2 = await app.handle(new Request('http://localhost:3000/data'));
|
|
24
|
+
expect(res2.status).toBe(200);
|
|
25
|
+
expect(await res2.json()).toEqual({ status: 'success', count: 42 });
|
|
26
|
+
|
|
27
|
+
const res3 = await app.handle(new Request('http://localhost:3000/users'));
|
|
28
|
+
expect(res3.status).toBe(200);
|
|
29
|
+
expect(await res3.json()).toEqual([{ id: '1', name: 'Yoga' }]);
|
|
30
|
+
});
|
|
31
|
+
});
|
package/test/sse.test.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { createSSEResponse } from '../src/stream/sse';
|
|
3
|
+
import { createContext } from '../src/core/context';
|
|
4
|
+
|
|
5
|
+
describe('FireAPI AI & Real-Time SSE Streaming Engine', () => {
|
|
6
|
+
it('formats SSE events and streams chunks via ReadableStream', async () => {
|
|
7
|
+
const sseResponse = createSSEResponse(async (stream) => {
|
|
8
|
+
await stream.send('Hello');
|
|
9
|
+
await stream.send({ token: 'world' }, { event: 'token', id: '1' });
|
|
10
|
+
stream.close();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
expect(sseResponse.status).toBe(200);
|
|
14
|
+
expect(sseResponse.headers.get('Content-Type')).toBe('text/event-stream; charset=utf-8');
|
|
15
|
+
expect(sseResponse.headers.get('Cache-Control')).toContain('no-cache');
|
|
16
|
+
|
|
17
|
+
const reader = sseResponse.body!.getReader();
|
|
18
|
+
const decoder = new TextDecoder();
|
|
19
|
+
let text = '';
|
|
20
|
+
|
|
21
|
+
while (true) {
|
|
22
|
+
const { done, value } = await reader.read();
|
|
23
|
+
if (done) break;
|
|
24
|
+
text += decoder.decode(value);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
expect(text).toContain('data: Hello\n\n');
|
|
28
|
+
expect(text).toContain('event: token\n');
|
|
29
|
+
expect(text).toContain('id: 1\n');
|
|
30
|
+
expect(text).toContain('data: {"token":"world"}\n\n');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('is accessible via ctx.stream.sse() helper', async () => {
|
|
34
|
+
const ctx = createContext({ req: new Request('http://localhost:3000/stream') });
|
|
35
|
+
const sseRes = ctx.stream.sse(async (stream) => {
|
|
36
|
+
await stream.send('stream-ready');
|
|
37
|
+
stream.close();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
expect(sseRes.status).toBe(200);
|
|
41
|
+
expect(sseRes.headers.get('Content-Type')).toBe('text/event-stream; charset=utf-8');
|
|
42
|
+
});
|
|
43
|
+
});
|