phantomback 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/LICENSE +21 -0
- package/README.md +459 -0
- package/bin/phantomback.js +39 -0
- package/package.json +63 -0
- package/src/cli/commands.js +138 -0
- package/src/data/relations.js +59 -0
- package/src/data/seeder.js +190 -0
- package/src/data/store.js +181 -0
- package/src/features/auth.js +123 -0
- package/src/features/delay.js +40 -0
- package/src/features/filters.js +84 -0
- package/src/features/pagination.js +38 -0
- package/src/features/search.js +18 -0
- package/src/features/sorting.js +47 -0
- package/src/index.js +67 -0
- package/src/schema/defaults.js +62 -0
- package/src/schema/parser.js +94 -0
- package/src/schema/validator.js +153 -0
- package/src/server/createServer.js +57 -0
- package/src/server/middleware.js +64 -0
- package/src/server/router.js +274 -0
- package/src/utils/helpers.js +114 -0
- package/src/utils/logger.js +58 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Response delay middleware.
|
|
3
|
+
*
|
|
4
|
+
* Adds configurable latency to simulate slow networks.
|
|
5
|
+
*
|
|
6
|
+
* Config:
|
|
7
|
+
* latency: 500 → fixed 500ms delay
|
|
8
|
+
* latency: [200, 1000] → random delay between 200-1000ms
|
|
9
|
+
* latency: { min: 200, max: 1000 }
|
|
10
|
+
*/
|
|
11
|
+
export function delayMiddleware(latency) {
|
|
12
|
+
if (!latency) return (_req, _res, next) => next();
|
|
13
|
+
|
|
14
|
+
return (_req, _res, next) => {
|
|
15
|
+
const ms = calculateDelay(latency);
|
|
16
|
+
if (ms <= 0) return next();
|
|
17
|
+
setTimeout(next, ms);
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Calculate delay in ms from various config formats
|
|
23
|
+
*/
|
|
24
|
+
function calculateDelay(latency) {
|
|
25
|
+
if (typeof latency === 'number') {
|
|
26
|
+
return latency;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (Array.isArray(latency) && latency.length === 2) {
|
|
30
|
+
const [min, max] = latency;
|
|
31
|
+
return Math.floor(Math.random() * (max - min + 1)) + min;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (typeof latency === 'object' && latency.min !== undefined && latency.max !== undefined) {
|
|
35
|
+
const { min, max } = latency;
|
|
36
|
+
return Math.floor(Math.random() * (max - min + 1)) + min;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { parseValue } from '../utils/helpers.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Filter records by query parameters.
|
|
5
|
+
*
|
|
6
|
+
* Supports:
|
|
7
|
+
* ?field=value → exact match
|
|
8
|
+
* ?field_gte=10 → greater than or equal
|
|
9
|
+
* ?field_lte=100 → less than or equal
|
|
10
|
+
* ?field_gt=10 → greater than
|
|
11
|
+
* ?field_lt=100 → less than
|
|
12
|
+
* ?field_ne=value → not equal
|
|
13
|
+
* ?field_like=text → contains (case-insensitive)
|
|
14
|
+
*/
|
|
15
|
+
const RESERVED_PARAMS = new Set([
|
|
16
|
+
'page',
|
|
17
|
+
'_page',
|
|
18
|
+
'limit',
|
|
19
|
+
'_limit',
|
|
20
|
+
'per_page',
|
|
21
|
+
'offset',
|
|
22
|
+
'sort',
|
|
23
|
+
'_sort',
|
|
24
|
+
'order',
|
|
25
|
+
'_order',
|
|
26
|
+
'q',
|
|
27
|
+
'_q',
|
|
28
|
+
'search',
|
|
29
|
+
'fields',
|
|
30
|
+
'_fields',
|
|
31
|
+
'select',
|
|
32
|
+
'_embed',
|
|
33
|
+
'_expand',
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
const OPERATORS = ['_gte', '_lte', '_gt', '_lt', '_ne', '_like'];
|
|
37
|
+
|
|
38
|
+
export function applyFilters(records, query) {
|
|
39
|
+
let filtered = [...records];
|
|
40
|
+
|
|
41
|
+
for (const [key, rawValue] of Object.entries(query)) {
|
|
42
|
+
if (RESERVED_PARAMS.has(key)) continue;
|
|
43
|
+
|
|
44
|
+
// Check for operator suffix
|
|
45
|
+
let fieldName = key;
|
|
46
|
+
let operator = 'eq';
|
|
47
|
+
|
|
48
|
+
for (const op of OPERATORS) {
|
|
49
|
+
if (key.endsWith(op)) {
|
|
50
|
+
fieldName = key.slice(0, -op.length);
|
|
51
|
+
operator = op.slice(1); // remove leading _
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const value = parseValue(rawValue);
|
|
57
|
+
|
|
58
|
+
filtered = filtered.filter((record) => {
|
|
59
|
+
const fieldValue = record[fieldName];
|
|
60
|
+
if (fieldValue === undefined) return true;
|
|
61
|
+
|
|
62
|
+
switch (operator) {
|
|
63
|
+
case 'eq':
|
|
64
|
+
return fieldValue == value; // loose equality intentional for string/number matching
|
|
65
|
+
case 'ne':
|
|
66
|
+
return fieldValue != value;
|
|
67
|
+
case 'gt':
|
|
68
|
+
return fieldValue > value;
|
|
69
|
+
case 'gte':
|
|
70
|
+
return fieldValue >= value;
|
|
71
|
+
case 'lt':
|
|
72
|
+
return fieldValue < value;
|
|
73
|
+
case 'lte':
|
|
74
|
+
return fieldValue <= value;
|
|
75
|
+
case 'like':
|
|
76
|
+
return String(fieldValue).toLowerCase().includes(String(value).toLowerCase());
|
|
77
|
+
default:
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return filtered;
|
|
84
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apply pagination to a list of records.
|
|
3
|
+
*
|
|
4
|
+
* Query params:
|
|
5
|
+
* ?page=1&limit=10
|
|
6
|
+
* ?_page=1&_limit=10
|
|
7
|
+
* ?offset=0&limit=10
|
|
8
|
+
*/
|
|
9
|
+
export function paginate(records, query) {
|
|
10
|
+
const page = parseInt(query.page || query._page, 10) || 1;
|
|
11
|
+
const limit = parseInt(query.limit || query._limit || query.per_page, 10) || 10;
|
|
12
|
+
const offset = parseInt(query.offset, 10);
|
|
13
|
+
|
|
14
|
+
const total = records.length;
|
|
15
|
+
const totalPages = Math.ceil(total / limit);
|
|
16
|
+
|
|
17
|
+
let start;
|
|
18
|
+
if (!isNaN(offset)) {
|
|
19
|
+
start = offset;
|
|
20
|
+
} else {
|
|
21
|
+
start = (page - 1) * limit;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const end = start + limit;
|
|
25
|
+
const data = records.slice(start, end);
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
data,
|
|
29
|
+
meta: {
|
|
30
|
+
page,
|
|
31
|
+
limit,
|
|
32
|
+
total,
|
|
33
|
+
totalPages,
|
|
34
|
+
hasNext: page < totalPages,
|
|
35
|
+
hasPrev: page > 1,
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { matchesSearch } from '../utils/helpers.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Full-text search across all string fields.
|
|
5
|
+
*
|
|
6
|
+
* Query params:
|
|
7
|
+
* ?q=search+term
|
|
8
|
+
* ?_q=search+term
|
|
9
|
+
* ?search=search+term
|
|
10
|
+
*/
|
|
11
|
+
export function applySearch(records, query) {
|
|
12
|
+
const searchTerm = query.q || query._q || query.search;
|
|
13
|
+
if (!searchTerm) return records;
|
|
14
|
+
|
|
15
|
+
return records.filter((record) => {
|
|
16
|
+
return Object.values(record).some((value) => matchesSearch(value, searchTerm));
|
|
17
|
+
});
|
|
18
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sort records by field and order.
|
|
3
|
+
*
|
|
4
|
+
* Query params:
|
|
5
|
+
* ?sort=name&order=asc
|
|
6
|
+
* ?_sort=name&_order=desc
|
|
7
|
+
* ?sort=name,-age (multi-field: comma-separated, prefix - for desc)
|
|
8
|
+
*/
|
|
9
|
+
export function applySort(records, query) {
|
|
10
|
+
const sortParam = query.sort || query._sort;
|
|
11
|
+
if (!sortParam) return records;
|
|
12
|
+
|
|
13
|
+
const orderParam = (query.order || query._order || 'asc').toLowerCase();
|
|
14
|
+
|
|
15
|
+
// Support multi-field sort: "name,-age,createdAt"
|
|
16
|
+
const sortFields = sortParam.split(',').map((field) => {
|
|
17
|
+
field = field.trim();
|
|
18
|
+
if (field.startsWith('-')) {
|
|
19
|
+
return { field: field.slice(1), order: 'desc' };
|
|
20
|
+
}
|
|
21
|
+
return { field, order: orderParam };
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const sorted = [...records];
|
|
25
|
+
sorted.sort((a, b) => {
|
|
26
|
+
for (const { field, order } of sortFields) {
|
|
27
|
+
const valA = a[field];
|
|
28
|
+
const valB = b[field];
|
|
29
|
+
|
|
30
|
+
if (valA === valB) continue;
|
|
31
|
+
if (valA === undefined || valA === null) return 1;
|
|
32
|
+
if (valB === undefined || valB === null) return -1;
|
|
33
|
+
|
|
34
|
+
let comparison;
|
|
35
|
+
if (typeof valA === 'string' && typeof valB === 'string') {
|
|
36
|
+
comparison = valA.localeCompare(valB);
|
|
37
|
+
} else {
|
|
38
|
+
comparison = valA < valB ? -1 : 1;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return order === 'desc' ? -comparison : comparison;
|
|
42
|
+
}
|
|
43
|
+
return 0;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
return sorted;
|
|
47
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { createServer } from './server/createServer.js';
|
|
2
|
+
import { parseConfig } from './schema/parser.js';
|
|
3
|
+
import { DEFAULT_RESOURCES } from './schema/defaults.js';
|
|
4
|
+
import { logger } from './utils/logger.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* PhantomBack — Instant Fake Backend Generator
|
|
8
|
+
*
|
|
9
|
+
* Library API entry point.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```js
|
|
13
|
+
* import { createPhantom } from 'phantomback';
|
|
14
|
+
*
|
|
15
|
+
* const phantom = await createPhantom({
|
|
16
|
+
* port: 3777,
|
|
17
|
+
* resources: {
|
|
18
|
+
* users: {
|
|
19
|
+
* fields: {
|
|
20
|
+
* name: { type: 'name', required: true },
|
|
21
|
+
* email: { type: 'email', unique: true },
|
|
22
|
+
* },
|
|
23
|
+
* seed: 25,
|
|
24
|
+
* },
|
|
25
|
+
* },
|
|
26
|
+
* });
|
|
27
|
+
*
|
|
28
|
+
* // phantom.stop() — stop the server
|
|
29
|
+
* // phantom.reset() — reset & re-seed all data
|
|
30
|
+
* // phantom.getStore() — export current data as JSON
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export async function createPhantom(userConfig = {}) {
|
|
34
|
+
const config = await parseConfig(userConfig);
|
|
35
|
+
|
|
36
|
+
// If no resources defined, use defaults
|
|
37
|
+
if (Object.keys(config.resources).length === 0) {
|
|
38
|
+
config.resources = DEFAULT_RESOURCES;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
logger.banner();
|
|
42
|
+
logger.table(config.resources);
|
|
43
|
+
|
|
44
|
+
const instance = await createServer(config);
|
|
45
|
+
|
|
46
|
+
logger.server(config.port);
|
|
47
|
+
|
|
48
|
+
return instance;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Create a zero-config phantom backend
|
|
53
|
+
* One line → full demo API
|
|
54
|
+
*/
|
|
55
|
+
export async function createPhantomZero(port = 3777) {
|
|
56
|
+
return createPhantom({
|
|
57
|
+
port,
|
|
58
|
+
resources: DEFAULT_RESOURCES,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Named exports for advanced usage
|
|
63
|
+
export { createServer } from './server/createServer.js';
|
|
64
|
+
export { parseConfig } from './schema/parser.js';
|
|
65
|
+
export { DEFAULT_RESOURCES } from './schema/defaults.js';
|
|
66
|
+
export { DataStore } from './data/store.js';
|
|
67
|
+
export { logger } from './utils/logger.js';
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default zero-config resources for PhantomBack
|
|
3
|
+
* Used when no config file is found or --zero flag is used
|
|
4
|
+
*/
|
|
5
|
+
export const DEFAULT_RESOURCES = {
|
|
6
|
+
users: {
|
|
7
|
+
fields: {
|
|
8
|
+
name: { type: 'name', required: true },
|
|
9
|
+
email: { type: 'email', unique: true },
|
|
10
|
+
username: { type: 'username' },
|
|
11
|
+
avatar: { type: 'avatar' },
|
|
12
|
+
bio: { type: 'bio' },
|
|
13
|
+
age: { type: 'number', min: 18, max: 65 },
|
|
14
|
+
role: { type: 'enum', values: ['admin', 'user', 'moderator'] },
|
|
15
|
+
isActive: { type: 'boolean' },
|
|
16
|
+
},
|
|
17
|
+
seed: 25,
|
|
18
|
+
auth: true,
|
|
19
|
+
},
|
|
20
|
+
posts: {
|
|
21
|
+
fields: {
|
|
22
|
+
title: { type: 'title', required: true },
|
|
23
|
+
body: { type: 'paragraphs', count: 3 },
|
|
24
|
+
slug: { type: 'slug' },
|
|
25
|
+
image: { type: 'image' },
|
|
26
|
+
published: { type: 'boolean' },
|
|
27
|
+
views: { type: 'number', min: 0, max: 10000 },
|
|
28
|
+
userId: { type: 'relation', resource: 'users' },
|
|
29
|
+
},
|
|
30
|
+
seed: 50,
|
|
31
|
+
},
|
|
32
|
+
comments: {
|
|
33
|
+
fields: {
|
|
34
|
+
body: { type: 'paragraph', required: true },
|
|
35
|
+
rating: { type: 'rating' },
|
|
36
|
+
userId: { type: 'relation', resource: 'users' },
|
|
37
|
+
postId: { type: 'relation', resource: 'posts' },
|
|
38
|
+
},
|
|
39
|
+
seed: 100,
|
|
40
|
+
},
|
|
41
|
+
products: {
|
|
42
|
+
fields: {
|
|
43
|
+
name: { type: 'product', required: true },
|
|
44
|
+
description: { type: 'description' },
|
|
45
|
+
price: { type: 'price' },
|
|
46
|
+
category: { type: 'category' },
|
|
47
|
+
image: { type: 'image' },
|
|
48
|
+
inStock: { type: 'boolean' },
|
|
49
|
+
rating: { type: 'rating' },
|
|
50
|
+
},
|
|
51
|
+
seed: 30,
|
|
52
|
+
},
|
|
53
|
+
todos: {
|
|
54
|
+
fields: {
|
|
55
|
+
title: { type: 'sentence', required: true },
|
|
56
|
+
completed: { type: 'boolean' },
|
|
57
|
+
priority: { type: 'enum', values: ['low', 'medium', 'high'] },
|
|
58
|
+
userId: { type: 'relation', resource: 'users' },
|
|
59
|
+
},
|
|
60
|
+
seed: 40,
|
|
61
|
+
},
|
|
62
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Default configuration
|
|
7
|
+
*/
|
|
8
|
+
export const DEFAULT_CONFIG = {
|
|
9
|
+
port: 3777,
|
|
10
|
+
prefix: '/api',
|
|
11
|
+
latency: 0,
|
|
12
|
+
auth: {
|
|
13
|
+
enabled: false,
|
|
14
|
+
secret: 'phantomback-secret-key',
|
|
15
|
+
expiresIn: '24h',
|
|
16
|
+
},
|
|
17
|
+
chaos: {
|
|
18
|
+
enabled: false,
|
|
19
|
+
},
|
|
20
|
+
resources: {},
|
|
21
|
+
snapshot: false,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Parse configuration from file or object
|
|
26
|
+
*/
|
|
27
|
+
export async function parseConfig(configPathOrObject) {
|
|
28
|
+
// If already an object, merge with defaults
|
|
29
|
+
if (configPathOrObject && typeof configPathOrObject === 'object') {
|
|
30
|
+
return mergeConfig(configPathOrObject);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Try to find config file
|
|
34
|
+
const configPath = configPathOrObject || (await findConfigFile());
|
|
35
|
+
|
|
36
|
+
if (!configPath) {
|
|
37
|
+
return { ...DEFAULT_CONFIG };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const ext = configPath.split('.').pop();
|
|
41
|
+
let userConfig;
|
|
42
|
+
|
|
43
|
+
if (ext === 'json') {
|
|
44
|
+
const raw = readFileSync(configPath, 'utf-8');
|
|
45
|
+
userConfig = JSON.parse(raw);
|
|
46
|
+
} else if (ext === 'js' || ext === 'mjs') {
|
|
47
|
+
const fileUrl = pathToFileURL(resolve(configPath)).href;
|
|
48
|
+
const mod = await import(fileUrl);
|
|
49
|
+
userConfig = mod.default || mod;
|
|
50
|
+
} else {
|
|
51
|
+
throw new Error(`Unsupported config file format: .${ext}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return mergeConfig(userConfig);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Search for config file in CWD
|
|
59
|
+
*/
|
|
60
|
+
async function findConfigFile() {
|
|
61
|
+
const cwd = process.cwd();
|
|
62
|
+
const candidates = [
|
|
63
|
+
'phantom.config.js',
|
|
64
|
+
'phantom.config.mjs',
|
|
65
|
+
'phantom.config.json',
|
|
66
|
+
'.phantomrc.json',
|
|
67
|
+
'.phantomrc.js',
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
for (const name of candidates) {
|
|
71
|
+
const fullPath = resolve(cwd, name);
|
|
72
|
+
if (existsSync(fullPath)) return fullPath;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Merge user config with defaults
|
|
80
|
+
*/
|
|
81
|
+
function mergeConfig(userConfig) {
|
|
82
|
+
return {
|
|
83
|
+
...DEFAULT_CONFIG,
|
|
84
|
+
...userConfig,
|
|
85
|
+
auth: {
|
|
86
|
+
...DEFAULT_CONFIG.auth,
|
|
87
|
+
...(userConfig.auth || {}),
|
|
88
|
+
},
|
|
89
|
+
chaos: {
|
|
90
|
+
...DEFAULT_CONFIG.chaos,
|
|
91
|
+
...(userConfig.chaos || {}),
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { sendError } from '../utils/helpers.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Validate incoming request body against the resource schema fields
|
|
5
|
+
*/
|
|
6
|
+
export function validateBody(fields, body, isPartial = false) {
|
|
7
|
+
const errors = [];
|
|
8
|
+
const sanitized = {};
|
|
9
|
+
|
|
10
|
+
if (!body || typeof body !== 'object') {
|
|
11
|
+
return {
|
|
12
|
+
valid: false,
|
|
13
|
+
errors: [{ field: '_body', message: 'Request body must be a JSON object' }],
|
|
14
|
+
data: null,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
for (const [fieldName, fieldDef] of Object.entries(fields)) {
|
|
19
|
+
const def = typeof fieldDef === 'string' ? { type: fieldDef } : fieldDef;
|
|
20
|
+
const value = body[fieldName];
|
|
21
|
+
|
|
22
|
+
// Skip relation fields — they're managed by the client directly
|
|
23
|
+
if (def.type === 'relation') {
|
|
24
|
+
if (value !== undefined) {
|
|
25
|
+
sanitized[fieldName] = value;
|
|
26
|
+
}
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Required check (only for full updates, not patches)
|
|
31
|
+
if (!isPartial && def.required && (value === undefined || value === null || value === '')) {
|
|
32
|
+
errors.push({ field: fieldName, message: `"${fieldName}" is required` });
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// If not provided on partial update, skip
|
|
37
|
+
if (value === undefined) {
|
|
38
|
+
if (!isPartial) {
|
|
39
|
+
// For full creates, use whatever default the field has
|
|
40
|
+
sanitized[fieldName] = value;
|
|
41
|
+
}
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Type validation
|
|
46
|
+
const typeError = validateType(fieldName, value, def);
|
|
47
|
+
if (typeError) {
|
|
48
|
+
errors.push(typeError);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Range validation
|
|
53
|
+
if (def.min !== undefined && typeof value === 'number' && value < def.min) {
|
|
54
|
+
errors.push({ field: fieldName, message: `"${fieldName}" must be at least ${def.min}` });
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (def.max !== undefined && typeof value === 'number' && value > def.max) {
|
|
58
|
+
errors.push({ field: fieldName, message: `"${fieldName}" must be at most ${def.max}` });
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Unique check is done at the route level (needs store access)
|
|
63
|
+
|
|
64
|
+
// Enum validation
|
|
65
|
+
if (def.type === 'enum' && def.values && !def.values.includes(value)) {
|
|
66
|
+
errors.push({
|
|
67
|
+
field: fieldName,
|
|
68
|
+
message: `"${fieldName}" must be one of: ${def.values.join(', ')}`,
|
|
69
|
+
});
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Email format
|
|
74
|
+
if (def.type === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
|
|
75
|
+
errors.push({ field: fieldName, message: `"${fieldName}" must be a valid email address` });
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
sanitized[fieldName] = value;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Allow extra fields not in schema (flexible mode)
|
|
83
|
+
for (const [key, value] of Object.entries(body)) {
|
|
84
|
+
if (!(key in fields) && key !== 'id' && key !== 'createdAt' && key !== 'updatedAt') {
|
|
85
|
+
sanitized[key] = value;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
valid: errors.length === 0,
|
|
91
|
+
errors,
|
|
92
|
+
data: sanitized,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Validate a single field's type
|
|
98
|
+
*/
|
|
99
|
+
function validateType(fieldName, value, def) {
|
|
100
|
+
const type = def.type;
|
|
101
|
+
|
|
102
|
+
switch (type) {
|
|
103
|
+
case 'number':
|
|
104
|
+
case 'float':
|
|
105
|
+
case 'price':
|
|
106
|
+
case 'rating':
|
|
107
|
+
if (typeof value !== 'number') {
|
|
108
|
+
return { field: fieldName, message: `"${fieldName}" must be a number` };
|
|
109
|
+
}
|
|
110
|
+
break;
|
|
111
|
+
|
|
112
|
+
case 'boolean':
|
|
113
|
+
if (typeof value !== 'boolean') {
|
|
114
|
+
return { field: fieldName, message: `"${fieldName}" must be a boolean` };
|
|
115
|
+
}
|
|
116
|
+
break;
|
|
117
|
+
|
|
118
|
+
case 'array':
|
|
119
|
+
if (!Array.isArray(value)) {
|
|
120
|
+
return { field: fieldName, message: `"${fieldName}" must be an array` };
|
|
121
|
+
}
|
|
122
|
+
break;
|
|
123
|
+
|
|
124
|
+
case 'object':
|
|
125
|
+
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
126
|
+
return { field: fieldName, message: `"${fieldName}" must be an object` };
|
|
127
|
+
}
|
|
128
|
+
break;
|
|
129
|
+
|
|
130
|
+
// String-like types — accept strings
|
|
131
|
+
default:
|
|
132
|
+
if (typeof value !== 'string' && typeof value !== 'number') {
|
|
133
|
+
return { field: fieldName, message: `"${fieldName}" must be a string` };
|
|
134
|
+
}
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Express middleware factory: validate request body
|
|
143
|
+
*/
|
|
144
|
+
export function validationMiddleware(fields, isPartial = false) {
|
|
145
|
+
return (req, res, next) => {
|
|
146
|
+
const { valid, errors, data } = validateBody(fields, req.body, isPartial);
|
|
147
|
+
if (!valid) {
|
|
148
|
+
return sendError(res, 400, 'Validation failed', errors);
|
|
149
|
+
}
|
|
150
|
+
req.validatedBody = data;
|
|
151
|
+
next();
|
|
152
|
+
};
|
|
153
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { createExpressApp, addErrorHandling } from './middleware.js';
|
|
2
|
+
import { createRouter } from './router.js';
|
|
3
|
+
import { createAuthRoutes } from '../features/auth.js';
|
|
4
|
+
import { seedAll } from '../data/seeder.js';
|
|
5
|
+
import { DataStore } from '../data/store.js';
|
|
6
|
+
import { logger } from '../utils/logger.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Create and start a PhantomBack server instance
|
|
10
|
+
*
|
|
11
|
+
* @param {object} config - Parsed configuration object
|
|
12
|
+
* @returns {{ app, server, store, stop, reset, getStore }}
|
|
13
|
+
*/
|
|
14
|
+
export async function createServer(config) {
|
|
15
|
+
const store = new DataStore();
|
|
16
|
+
const app = createExpressApp(config);
|
|
17
|
+
|
|
18
|
+
// Seed data
|
|
19
|
+
seedAll(config.resources, store);
|
|
20
|
+
|
|
21
|
+
// Register auth routes if any resource uses auth
|
|
22
|
+
const hasAuth = Object.values(config.resources).some((r) => r.auth);
|
|
23
|
+
if (hasAuth) {
|
|
24
|
+
createAuthRoutes(app, store, config);
|
|
25
|
+
logger.route('POST', `${config.prefix}/auth/register`);
|
|
26
|
+
logger.route('POST', `${config.prefix}/auth/login`);
|
|
27
|
+
logger.route('GET', `${config.prefix}/auth/me`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Register resource routes
|
|
31
|
+
const router = createRouter(config, store);
|
|
32
|
+
app.use(router);
|
|
33
|
+
|
|
34
|
+
// Error handling (must be last)
|
|
35
|
+
addErrorHandling(app);
|
|
36
|
+
|
|
37
|
+
// Start listening
|
|
38
|
+
const server = await new Promise((resolve) => {
|
|
39
|
+
const srv = app.listen(config.port, () => resolve(srv));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
app,
|
|
44
|
+
server,
|
|
45
|
+
store,
|
|
46
|
+
stop: () =>
|
|
47
|
+
new Promise((resolve) => {
|
|
48
|
+
server.close(resolve);
|
|
49
|
+
}),
|
|
50
|
+
reset: () => {
|
|
51
|
+
store.reset();
|
|
52
|
+
seedAll(config.resources, store);
|
|
53
|
+
logger.info('Store has been reset and re-seeded');
|
|
54
|
+
},
|
|
55
|
+
getStore: () => store.toJSON(),
|
|
56
|
+
};
|
|
57
|
+
}
|