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.
@@ -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
+ }