@koltakov/ffa-core 0.8.0 → 0.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +377 -299
- package/dist/cli.js +260 -38
- package/dist/index.d.ts +15 -4
- package/dist/index.js +17 -4
- package/dist/index.js.map +1 -1
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
# ffa-core
|
|
2
2
|
|
|
3
|
-
**
|
|
3
|
+
**Frontend First API** — instant mock REST API for frontend development.
|
|
4
4
|
|
|
5
|
-
Stop waiting for the backend. Describe your data, run one command, get a working API.
|
|
5
|
+
Stop waiting for the backend. Describe your data in one config file, run one command, get a fully working REST API.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx @koltakov/ffa-core init
|
|
9
|
+
npx @koltakov/ffa-core dev
|
|
10
|
+
```
|
|
6
11
|
|
|
7
12
|
---
|
|
8
13
|
|
|
9
14
|
## Why ffa?
|
|
10
15
|
|
|
11
|
-
When building a frontend you need a real API to work against — but the backend isn't ready yet. `ffa` generates a fully functional REST API from a simple config file. No JSON files to maintain, no manual routes, no extra services.
|
|
12
|
-
|
|
13
16
|
- **Zero boilerplate** — one config file, one command
|
|
14
|
-
- **Auto CRUD** —
|
|
17
|
+
- **Auto CRUD** — 6 routes per entity, out of the box
|
|
15
18
|
- **Smart fake data** — field names drive generation (`email`, `price`, `avatar`, ...)
|
|
16
|
-
- **
|
|
19
|
+
- **Typed DSL** — `string().email().required()` with full TypeScript autocomplete
|
|
20
|
+
- **Zod validation** — POST/PUT/PATCH bodies validated against your schema
|
|
21
|
+
- **Relations** — `belongsTo` / `hasMany` with inline join via `?include=`
|
|
17
22
|
- **Swagger UI** — auto-generated docs at `/docs`
|
|
18
23
|
- **Delay simulation** — test loading states with artificial latency
|
|
19
24
|
- **TypeScript or JSON** — pick your config format
|
|
@@ -23,101 +28,94 @@ When building a frontend you need a real API to work against — but the backend
|
|
|
23
28
|
## Quick Start
|
|
24
29
|
|
|
25
30
|
```bash
|
|
26
|
-
# 1. Install
|
|
27
31
|
npm install @koltakov/ffa-core
|
|
28
32
|
|
|
29
|
-
|
|
30
|
-
npx ffa
|
|
31
|
-
|
|
32
|
-
# 3. Start
|
|
33
|
-
npx ffa dev
|
|
33
|
+
npx ffa init # scaffold ffa.config.ts
|
|
34
|
+
npx ffa dev # start server
|
|
34
35
|
```
|
|
35
36
|
|
|
36
|
-
Or skip `init` and write `ffa.config.ts` manually (see below).
|
|
37
|
-
|
|
38
37
|
---
|
|
39
38
|
|
|
40
|
-
## Config
|
|
39
|
+
## Config
|
|
41
40
|
|
|
42
41
|
```ts
|
|
43
|
-
|
|
42
|
+
// ffa.config.ts
|
|
43
|
+
import {
|
|
44
|
+
defineConfig, entity,
|
|
45
|
+
string, number, boolean, enumField,
|
|
46
|
+
belongsTo, hasMany, object, array,
|
|
47
|
+
} from '@koltakov/ffa-core'
|
|
44
48
|
|
|
45
49
|
export default defineConfig({
|
|
46
50
|
server: {
|
|
47
51
|
port: 3333,
|
|
48
|
-
delay: [200, 600], // artificial latency
|
|
52
|
+
delay: [200, 600], // artificial latency in ms (number or [min, max])
|
|
49
53
|
persist: true, // save data to ffa-data.json between restarts
|
|
54
|
+
errorRate: 0.05, // 5% of requests return 500 (for error state testing)
|
|
50
55
|
},
|
|
51
56
|
|
|
52
57
|
entities: {
|
|
53
58
|
User: entity({
|
|
54
|
-
name:
|
|
55
|
-
email:
|
|
56
|
-
role:
|
|
57
|
-
avatar: string().optional(),
|
|
58
|
-
|
|
59
|
+
name: string().fullName().required(),
|
|
60
|
+
email: string().email().required(),
|
|
61
|
+
role: enumField(['admin', 'editor', 'viewer']).required(),
|
|
62
|
+
avatar: string().avatar().optional(),
|
|
63
|
+
address: object({
|
|
64
|
+
city: string().city(),
|
|
65
|
+
country: string().country(),
|
|
66
|
+
zip: string().zip(),
|
|
67
|
+
}),
|
|
68
|
+
phones: array(string().phone()),
|
|
69
|
+
}, {
|
|
70
|
+
count: 20,
|
|
71
|
+
// Guaranteed records — appear first, rest are generated up to count
|
|
72
|
+
seed: [
|
|
73
|
+
{ name: 'Admin User', email: 'admin@example.com', role: 'admin' },
|
|
74
|
+
],
|
|
75
|
+
}),
|
|
59
76
|
|
|
60
77
|
Post: entity({
|
|
61
|
-
title:
|
|
62
|
-
body:
|
|
63
|
-
status:
|
|
64
|
-
price:
|
|
78
|
+
title: string().sentence().required(),
|
|
79
|
+
body: string().paragraph().required(),
|
|
80
|
+
status: enumField(['draft', 'published', 'archived']).required(),
|
|
81
|
+
price: number().price().required(),
|
|
82
|
+
rating: number().rating().optional(),
|
|
65
83
|
authorId: belongsTo('User'),
|
|
84
|
+
tagIds: hasMany('Tag'),
|
|
66
85
|
}, {
|
|
67
|
-
count:
|
|
86
|
+
count: 50,
|
|
68
87
|
meta: {
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
total: (items) => items.length,
|
|
75
|
-
avgPrice: (items) => +(items.reduce((s, i) => s + (i.price as number), 0) / items.length).toFixed(2),
|
|
76
|
-
byStatus: (items) => Object.fromEntries(
|
|
77
|
-
['draft', 'published', 'archived'].map(s => [s, items.filter(i => i.status === s).length])
|
|
78
|
-
),
|
|
88
|
+
currency: 'USD', // static value
|
|
89
|
+
total: (items) => items.length, // computed from filtered items
|
|
90
|
+
avgPrice: (items) => +(
|
|
91
|
+
items.reduce((s, i) => s + (i.price as number), 0) / items.length
|
|
92
|
+
).toFixed(2),
|
|
79
93
|
},
|
|
80
94
|
}),
|
|
95
|
+
|
|
96
|
+
Tag: entity({
|
|
97
|
+
name: string().word().required(),
|
|
98
|
+
color: string().hexColor().optional(),
|
|
99
|
+
}, { count: 15 }),
|
|
81
100
|
},
|
|
82
101
|
})
|
|
83
102
|
```
|
|
84
103
|
|
|
85
|
-
## Config: JSON (no TypeScript needed)
|
|
86
|
-
|
|
87
|
-
```json
|
|
88
|
-
{
|
|
89
|
-
"server": { "port": 3333, "delay": 300 },
|
|
90
|
-
"entities": {
|
|
91
|
-
"Product": {
|
|
92
|
-
"count": 20,
|
|
93
|
-
"fields": {
|
|
94
|
-
"title": "string!",
|
|
95
|
-
"price": "number",
|
|
96
|
-
"status": ["draft", "published", "archived"],
|
|
97
|
-
"inStock": "boolean",
|
|
98
|
-
"createdAt": "datetime"
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
```
|
|
104
|
-
|
|
105
|
-
Save as `ffa.config.json`. Type suffix `!` means required (`"string!"` → required string). An array is shorthand for `enumField`.
|
|
106
|
-
|
|
107
104
|
---
|
|
108
105
|
|
|
109
106
|
## CLI
|
|
110
107
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
ffa dev
|
|
114
|
-
ffa dev
|
|
115
|
-
ffa dev
|
|
116
|
-
ffa dev -
|
|
117
|
-
|
|
118
|
-
ffa
|
|
119
|
-
ffa
|
|
120
|
-
|
|
108
|
+
| Command | Description |
|
|
109
|
+
|---------|-------------|
|
|
110
|
+
| `ffa dev` | Start the dev server |
|
|
111
|
+
| `ffa dev -p 4000` | Override port |
|
|
112
|
+
| `ffa dev -w` | Watch mode — restart on config changes |
|
|
113
|
+
| `ffa dev -o` | Open Swagger UI in browser on start |
|
|
114
|
+
| `ffa init` | Scaffold `ffa.config.ts` in current directory |
|
|
115
|
+
| `ffa inspect` | Show config structure without starting the server |
|
|
116
|
+
| `ffa snapshot` | Export current in-memory DB to JSON (server must be running) |
|
|
117
|
+
| `ffa snapshot -o dump.json` | Custom output path |
|
|
118
|
+
| `ffa reset` | Delete `ffa-data.json` (clears persisted data) |
|
|
121
119
|
|
|
122
120
|
---
|
|
123
121
|
|
|
@@ -125,341 +123,421 @@ ffa reset # delete ffa-data.json (clears persisted data)
|
|
|
125
123
|
|
|
126
124
|
For every entity `Foo` ffa creates six routes:
|
|
127
125
|
|
|
128
|
-
| Method
|
|
129
|
-
|
|
130
|
-
| `GET`
|
|
131
|
-
| `GET`
|
|
132
|
-
| `POST`
|
|
133
|
-
| `PUT`
|
|
134
|
-
| `PATCH`
|
|
135
|
-
| `DELETE`
|
|
126
|
+
| Method | Path | Description | Status |
|
|
127
|
+
|--------|------|-------------|--------|
|
|
128
|
+
| `GET` | `/foos` | List all records | 200 |
|
|
129
|
+
| `GET` | `/foos/:id` | Get single record | 200, 404 |
|
|
130
|
+
| `POST` | `/foos` | Create a record | 201, 422 |
|
|
131
|
+
| `PUT` | `/foos/:id` | Full replace | 200, 404, 422 |
|
|
132
|
+
| `PATCH` | `/foos/:id` | Partial update | 200, 404, 422 |
|
|
133
|
+
| `DELETE` | `/foos/:id` | Delete a record | 200, 404 |
|
|
136
134
|
|
|
137
135
|
> Names are auto-pluralized: `Product` → `/products`, `Category` → `/categories`.
|
|
138
136
|
|
|
137
|
+
### System endpoints
|
|
138
|
+
|
|
139
|
+
| Method | Path | Description |
|
|
140
|
+
|--------|------|-------------|
|
|
141
|
+
| `POST` | `/__reset` | Regenerate all data |
|
|
142
|
+
| `GET` | `/__snapshot` | Dump current DB to JSON |
|
|
143
|
+
| `GET` | `/docs` | Swagger UI |
|
|
144
|
+
| `GET` | `/openapi.json` | OpenAPI 3.0 spec |
|
|
145
|
+
|
|
139
146
|
---
|
|
140
147
|
|
|
141
|
-
## Query Params
|
|
148
|
+
## Query Params (GET /list)
|
|
142
149
|
|
|
143
|
-
|
|
144
|
-
|---------------|------------------------------|-------------------------------------|
|
|
145
|
-
| `page` | `?page=2` | Page number (default: 1) |
|
|
146
|
-
| `limit` | `?limit=10` | Records per page |
|
|
147
|
-
| `sort` | `?sort=price` | Sort by field |
|
|
148
|
-
| `order` | `?order=desc` | `asc` (default) or `desc` |
|
|
149
|
-
| `search` | `?search=apple` | Full-text search across string fields|
|
|
150
|
-
| `{field}` | `?status=published` | Exact field filter |
|
|
150
|
+
### Pagination, sorting, search
|
|
151
151
|
|
|
152
|
-
All params are combinable:
|
|
153
152
|
```
|
|
154
|
-
|
|
153
|
+
?page=2&limit=10
|
|
154
|
+
?sort=price&order=desc
|
|
155
|
+
?search=apple full-text search across all string fields
|
|
156
|
+
?status=published exact match filter
|
|
155
157
|
```
|
|
156
158
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
159
|
+
### Filter operators
|
|
160
|
+
|
|
161
|
+
```
|
|
162
|
+
?price_gte=100 price >= 100
|
|
163
|
+
?price_lte=500 price <= 500
|
|
164
|
+
?price_gt=0 price > 0
|
|
165
|
+
?price_lt=1000 price < 1000
|
|
166
|
+
?price_ne=0 price != 0
|
|
167
|
+
?status_in=draft,published status in ['draft', 'published']
|
|
168
|
+
?title_contains=hello title contains 'hello' (case-insensitive)
|
|
165
169
|
```
|
|
166
170
|
|
|
167
|
-
|
|
171
|
+
All params are combinable:
|
|
172
|
+
```
|
|
173
|
+
GET /posts?search=react&status_in=draft,published&price_gte=10&sort=price&order=asc&page=1&limit=20
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### Response envelope
|
|
177
|
+
|
|
168
178
|
```json
|
|
169
179
|
{
|
|
170
|
-
"data": [ ... ],
|
|
180
|
+
"data": [{ "id": "...", "title": "...", "price": 49.99 }],
|
|
171
181
|
"meta": {
|
|
172
|
-
"pagination": { "total":
|
|
182
|
+
"pagination": { "total": 47, "page": 1, "limit": 10, "pages": 5 },
|
|
173
183
|
"currency": "USD",
|
|
174
|
-
"avgPrice":
|
|
175
|
-
"byStatus": { "draft": 5, "published": 15, "archived": 3 }
|
|
184
|
+
"avgPrice": 124.50
|
|
176
185
|
}
|
|
177
186
|
}
|
|
178
187
|
```
|
|
179
188
|
|
|
180
|
-
|
|
189
|
+
Header: `X-Total-Count: 47`
|
|
181
190
|
|
|
182
191
|
---
|
|
183
192
|
|
|
184
|
-
##
|
|
193
|
+
## Field Types
|
|
185
194
|
|
|
186
|
-
|
|
195
|
+
### Primitives
|
|
187
196
|
|
|
188
197
|
```ts
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
rating: number().rating().required(),
|
|
195
|
-
}, {
|
|
196
|
-
count: 50,
|
|
197
|
-
meta: {
|
|
198
|
-
// Static — always the same regardless of filters
|
|
199
|
-
currency: 'USD',
|
|
200
|
-
apiVersion: 2,
|
|
201
|
-
sortOptions: ['price', 'rating', 'title'],
|
|
202
|
-
|
|
203
|
-
// Functions — receive items after search/filter, before pagination
|
|
204
|
-
total: (items) => items.length,
|
|
205
|
-
avgPrice: (items) => +(items.reduce((s, i) => s + (i.price as number), 0) / items.length).toFixed(2),
|
|
206
|
-
avgRating: (items) => +(items.reduce((s, i) => s + (i.rating as number), 0) / items.length).toFixed(1),
|
|
207
|
-
priceRange: (items) => ({
|
|
208
|
-
min: Math.min(...items.map(i => i.price as number)),
|
|
209
|
-
max: Math.max(...items.map(i => i.price as number)),
|
|
210
|
-
}),
|
|
211
|
-
byStatus: (items) => Object.fromEntries(
|
|
212
|
-
['active', 'sale', 'soldout'].map(s => [s, items.filter(i => i.status === s).length])
|
|
213
|
-
),
|
|
214
|
-
},
|
|
215
|
-
})
|
|
198
|
+
string() // chainable hints — see below
|
|
199
|
+
number() // chainable hints — see below
|
|
200
|
+
boolean()
|
|
201
|
+
uuid()
|
|
202
|
+
datetime()
|
|
216
203
|
```
|
|
217
204
|
|
|
218
|
-
|
|
219
|
-
```json
|
|
220
|
-
{
|
|
221
|
-
"data": [ ... ],
|
|
222
|
-
"meta": {
|
|
223
|
-
"pagination": { "total": 31, "page": 1, "limit": 10, "pages": 4 },
|
|
224
|
-
"currency": "USD",
|
|
225
|
-
"apiVersion": 2,
|
|
226
|
-
"sortOptions": ["price", "rating", "title"],
|
|
227
|
-
"total": 31,
|
|
228
|
-
"avgPrice": 124.50,
|
|
229
|
-
"avgRating": 4.1,
|
|
230
|
-
"priceRange": { "min": 9.99, "max": 599.99 },
|
|
231
|
-
"byStatus": { "active": 31, "sale": 0, "soldout": 0 }
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
```
|
|
235
|
-
|
|
236
|
-
> Meta functions receive only the filtered items — so `avgPrice` and `byStatus` always reflect the current search/filter result, not the full dataset.
|
|
237
|
-
|
|
238
|
-
---
|
|
239
|
-
|
|
240
|
-
## Relations
|
|
205
|
+
### Enum
|
|
241
206
|
|
|
242
207
|
```ts
|
|
243
|
-
|
|
208
|
+
enumField(['draft', 'published', 'archived'])
|
|
209
|
+
```
|
|
244
210
|
|
|
245
|
-
|
|
246
|
-
User: entity({ name: string().required() }),
|
|
211
|
+
### Relations
|
|
247
212
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
tagIds: hasMany('Tag'), // stores 1–3 random Tag ids
|
|
252
|
-
}),
|
|
253
|
-
}
|
|
213
|
+
```ts
|
|
214
|
+
belongsTo('User') // stores a random User id
|
|
215
|
+
hasMany('Tag') // stores an array of 1–3 Tag ids
|
|
254
216
|
```
|
|
255
217
|
|
|
256
|
-
**Inline join** on GET
|
|
218
|
+
**Inline join** on `GET /:id`:
|
|
257
219
|
```
|
|
258
220
|
GET /posts/abc?include=authorId,tagIds
|
|
259
221
|
```
|
|
260
|
-
Returns the related objects inline instead of just ids.
|
|
261
222
|
|
|
262
|
-
|
|
223
|
+
### Nested structures (v0.12.0)
|
|
263
224
|
|
|
264
|
-
|
|
225
|
+
```ts
|
|
226
|
+
// Nested object — generates each field recursively
|
|
227
|
+
address: object({
|
|
228
|
+
city: string().city(),
|
|
229
|
+
country: string().country(),
|
|
230
|
+
zip: string().zip(),
|
|
231
|
+
})
|
|
265
232
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
| `uuid()` | UUID v4 | |
|
|
272
|
-
| `datetime()` | ISO 8601 string | |
|
|
273
|
-
| `enumField([...])` | Random value from array | Also validates on write |
|
|
274
|
-
| `belongsTo('Entity')` | Random id from that entity | |
|
|
275
|
-
| `hasMany('Entity')` | Array of 1–3 ids from that entity| |
|
|
233
|
+
// Array of typed items
|
|
234
|
+
tags: array(string().word())
|
|
235
|
+
phones: array(string().phone(), [1, 4]) // [min, max] items
|
|
236
|
+
scores: array(number().rating())
|
|
237
|
+
```
|
|
276
238
|
|
|
277
239
|
---
|
|
278
240
|
|
|
279
|
-
##
|
|
280
|
-
|
|
281
|
-
When field name alone isn't enough, chain a hint method to control exactly what gets generated.
|
|
282
|
-
Hints take priority over smart field-name detection.
|
|
241
|
+
## String Fake Hints
|
|
283
242
|
|
|
284
|
-
|
|
243
|
+
Chain a method on `string()` to control what gets generated:
|
|
285
244
|
|
|
286
245
|
```ts
|
|
287
246
|
// Internet
|
|
288
|
-
string().email() //
|
|
289
|
-
string().url() //
|
|
290
|
-
string().domain() //
|
|
291
|
-
string().ip() //
|
|
292
|
-
string().username() //
|
|
247
|
+
string().email() // "user@example.com"
|
|
248
|
+
string().url() // "https://example.com"
|
|
249
|
+
string().domain() // "example.com"
|
|
250
|
+
string().ip() // "192.168.1.1"
|
|
251
|
+
string().username() // "john_doe"
|
|
293
252
|
|
|
294
253
|
// Media
|
|
295
|
-
string().image()
|
|
296
|
-
string().
|
|
254
|
+
string().image() // "https://picsum.photos/seed/xxx/1280/720" (default 16:9)
|
|
255
|
+
string().image(300, 450) // "https://picsum.photos/seed/xxx/300/450" (portrait 2:3)
|
|
256
|
+
string().image(1920, 1080) // "https://picsum.photos/seed/xxx/1920/1080" (Full HD)
|
|
257
|
+
string().avatar() // avatar URL
|
|
297
258
|
|
|
298
259
|
// Person
|
|
299
|
-
string().firstName() //
|
|
300
|
-
string().lastName() //
|
|
301
|
-
string().fullName() //
|
|
302
|
-
string().phone() //
|
|
260
|
+
string().firstName() // "Alice"
|
|
261
|
+
string().lastName() // "Johnson"
|
|
262
|
+
string().fullName() // "Alice Johnson"
|
|
263
|
+
string().phone() // "+1-555-234-5678"
|
|
303
264
|
|
|
304
265
|
// Location
|
|
305
|
-
string().city() //
|
|
306
|
-
string().country() //
|
|
307
|
-
string().address() //
|
|
308
|
-
string().zip() //
|
|
309
|
-
string().locale() //
|
|
266
|
+
string().city() // "Berlin"
|
|
267
|
+
string().country() // "Germany"
|
|
268
|
+
string().address() // "12 Oak Street"
|
|
269
|
+
string().zip() // "10115"
|
|
270
|
+
string().locale() // "DE"
|
|
310
271
|
|
|
311
272
|
// Business
|
|
312
|
-
string().company() //
|
|
313
|
-
string().jobTitle() //
|
|
314
|
-
string().department() //
|
|
315
|
-
string().currency() //
|
|
273
|
+
string().company() // "Acme Corp"
|
|
274
|
+
string().jobTitle() // "Senior Engineer"
|
|
275
|
+
string().department() // "Electronics"
|
|
276
|
+
string().currency() // "EUR"
|
|
316
277
|
|
|
317
278
|
// Text
|
|
318
|
-
string().word() //
|
|
319
|
-
string().slug() //
|
|
320
|
-
string().sentence() //
|
|
321
|
-
string().paragraph() //
|
|
322
|
-
string().bio() //
|
|
279
|
+
string().word() // "matrix"
|
|
280
|
+
string().slug() // "hello-world"
|
|
281
|
+
string().sentence() // "The quick brown fox."
|
|
282
|
+
string().paragraph() // "Lorem ipsum dolor..."
|
|
283
|
+
string().bio() // "Lorem ipsum dolor..."
|
|
323
284
|
|
|
324
285
|
// Visual
|
|
325
|
-
string().color() //
|
|
326
|
-
string().hexColor() //
|
|
286
|
+
string().color() // "azure"
|
|
287
|
+
string().hexColor() // "#A3F5C2"
|
|
327
288
|
|
|
328
289
|
// Id
|
|
329
|
-
string().uuid() //
|
|
290
|
+
string().uuid() // UUID v4
|
|
330
291
|
```
|
|
331
292
|
|
|
332
|
-
|
|
293
|
+
## Number Fake Hints
|
|
333
294
|
|
|
334
295
|
```ts
|
|
335
|
-
number().price() //
|
|
336
|
-
number().age() //
|
|
337
|
-
number().rating() //
|
|
338
|
-
number().percent() //
|
|
339
|
-
number().lat() //
|
|
340
|
-
number().lng() //
|
|
341
|
-
number().year() //
|
|
296
|
+
number().price() // 19.99
|
|
297
|
+
number().age() // 34
|
|
298
|
+
number().rating() // 4
|
|
299
|
+
number().percent() // 72
|
|
300
|
+
number().lat() // 51.5074
|
|
301
|
+
number().lng() // -0.1278
|
|
302
|
+
number().year() // 2021
|
|
342
303
|
```
|
|
343
304
|
|
|
344
|
-
|
|
305
|
+
## Smart Field-Name Detection
|
|
306
|
+
|
|
307
|
+
When no hint is set, ffa infers the right value from the field name automatically:
|
|
308
|
+
|
|
309
|
+
| Field name pattern | Generated value |
|
|
310
|
+
|--------------------|----------------|
|
|
311
|
+
| `email`, `mail` | Email address |
|
|
312
|
+
| `name`, `firstName` | First name |
|
|
313
|
+
| `lastName`, `surname` | Last name |
|
|
314
|
+
| `phone`, `tel`, `mobile` | Phone number |
|
|
315
|
+
| `city`, `country`, `address` | Location values |
|
|
316
|
+
| `url`, `website`, `link` | URL |
|
|
317
|
+
| `avatar`, `photo` | Avatar URL |
|
|
318
|
+
| `image` | Image URL |
|
|
319
|
+
| `company` | Company name |
|
|
320
|
+
| `title`, `heading` | Short sentence |
|
|
321
|
+
| `description`, `bio`, `text` | Paragraph |
|
|
322
|
+
| `price`, `cost`, `amount` | Price |
|
|
323
|
+
| `color` | Color name |
|
|
324
|
+
|
|
325
|
+
---
|
|
326
|
+
|
|
327
|
+
## Field Rules
|
|
345
328
|
|
|
346
|
-
|
|
329
|
+
All builders share these chainable rules:
|
|
330
|
+
|
|
331
|
+
| Method | Effect |
|
|
332
|
+
|--------|--------|
|
|
333
|
+
| `.required()` | Field required in POST/PUT |
|
|
334
|
+
| `.optional()` | Field can be omitted (default) |
|
|
335
|
+
| `.min(n)` | Min string length / min number value |
|
|
336
|
+
| `.max(n)` | Max string length / max number value |
|
|
337
|
+
| `.readonly()` | Excluded from create/update validation |
|
|
338
|
+
| `.default(val)` | Default value |
|
|
339
|
+
| `.fake(hint)` | Explicit faker hint |
|
|
340
|
+
| `.image(w, h)` | Image dimensions (only for `string().image()`) |
|
|
341
|
+
|
|
342
|
+
---
|
|
343
|
+
|
|
344
|
+
## Seed Data (v0.9.0)
|
|
345
|
+
|
|
346
|
+
Guarantee specific records always exist in your entity:
|
|
347
347
|
|
|
348
348
|
```ts
|
|
349
|
-
|
|
350
|
-
|
|
349
|
+
User: entity({
|
|
350
|
+
name: string().fullName().required(),
|
|
351
|
+
role: enumField(['admin', 'editor', 'viewer']).required(),
|
|
352
|
+
}, {
|
|
353
|
+
count: 20,
|
|
354
|
+
seed: [
|
|
355
|
+
{ id: 'admin-1', name: 'Admin User', role: 'admin' },
|
|
356
|
+
{ name: 'Guest User', role: 'viewer' }, // id auto-generated if omitted
|
|
357
|
+
],
|
|
358
|
+
})
|
|
351
359
|
```
|
|
352
360
|
|
|
353
|
-
|
|
361
|
+
Seed records appear first. The remaining `count - seed.length` records are generated.
|
|
362
|
+
|
|
363
|
+
---
|
|
364
|
+
|
|
365
|
+
## Custom Meta (v0.8.0)
|
|
366
|
+
|
|
367
|
+
Attach extra fields to the list response `meta`. Values can be static or computed from the current filtered dataset (before pagination):
|
|
354
368
|
|
|
355
369
|
```ts
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
lat: number().lat().required(),
|
|
372
|
-
lng: number().lng().required(),
|
|
373
|
-
}),
|
|
374
|
-
```
|
|
375
|
-
|
|
376
|
-
### Smart Faker by Field Name (auto, no hint needed)
|
|
377
|
-
|
|
378
|
-
If the field name matches a known keyword, ffa generates the right value automatically — no hint required:
|
|
379
|
-
|
|
380
|
-
| Field name pattern | Generated value |
|
|
381
|
-
|-----------------------------|-------------------------|
|
|
382
|
-
| `email`, `mail` | Email address |
|
|
383
|
-
| `name`, `firstName` | First name |
|
|
384
|
-
| `lastName`, `surname` | Last name |
|
|
385
|
-
| `phone`, `tel`, `mobile` | Phone number |
|
|
386
|
-
| `city`, `country`, `address`| Location values |
|
|
387
|
-
| `url`, `website`, `link` | URL |
|
|
388
|
-
| `avatar`, `photo` | Avatar URL |
|
|
389
|
-
| `image` | Image URL |
|
|
390
|
-
| `company` | Company name |
|
|
391
|
-
| `title`, `heading` | Short sentence |
|
|
392
|
-
| `description`, `bio`, `text`| Paragraph |
|
|
393
|
-
| `price`, `cost`, `amount` | Price |
|
|
394
|
-
| `color` | Color name |
|
|
370
|
+
Product: entity({ price: number().price(), status: enumField(['active', 'sale']) }, {
|
|
371
|
+
count: 50,
|
|
372
|
+
meta: {
|
|
373
|
+
currency: 'USD', // static
|
|
374
|
+
apiVersion: 2, // static
|
|
375
|
+
total: (items) => items.length, // computed
|
|
376
|
+
avgPrice: (items) => +(items.reduce((s, i) => s + (i.price as number), 0) / items.length).toFixed(2),
|
|
377
|
+
byStatus: (items) => Object.fromEntries(
|
|
378
|
+
['active', 'sale'].map(s => [s, items.filter(i => i.status === s).length])
|
|
379
|
+
),
|
|
380
|
+
},
|
|
381
|
+
})
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
Meta functions receive items **after** search/filter but **before** pagination — so aggregates always reflect the current query.
|
|
395
385
|
|
|
396
386
|
---
|
|
397
387
|
|
|
398
|
-
##
|
|
388
|
+
## Seed Data Export (v0.15.0)
|
|
389
|
+
|
|
390
|
+
Export the current in-memory DB while the server is running — useful for generating fixture files:
|
|
391
|
+
|
|
392
|
+
```bash
|
|
393
|
+
ffa snapshot # → ffa-snapshot.json
|
|
394
|
+
ffa snapshot -o fixtures.json
|
|
395
|
+
ffa snapshot -p 4000 # specify port if not in config
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
Or call directly:
|
|
399
|
+
```
|
|
400
|
+
GET /__snapshot → { "User": [...], "Post": [...] }
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
---
|
|
404
|
+
|
|
405
|
+
## Inspect Config (v0.16.0)
|
|
406
|
+
|
|
407
|
+
Print your config structure without starting the server:
|
|
408
|
+
|
|
409
|
+
```bash
|
|
410
|
+
ffa inspect
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
```
|
|
414
|
+
FFA inspect port 3333
|
|
415
|
+
|
|
416
|
+
User 20 records +1 seed
|
|
417
|
+
* name string [fullName]
|
|
418
|
+
* email string [email]
|
|
419
|
+
* role enum (admin | editor | viewer)
|
|
420
|
+
avatar string [avatar]
|
|
421
|
+
address object { city, country, zip }
|
|
422
|
+
phones array of string
|
|
423
|
+
|
|
424
|
+
Post 50 records
|
|
425
|
+
* title string [sentence]
|
|
426
|
+
* body string [paragraph]
|
|
427
|
+
* status enum (draft | published | archived)
|
|
428
|
+
* price number [price]
|
|
429
|
+
authorId belongsTo → User
|
|
430
|
+
tagIds hasMany → Tag
|
|
431
|
+
meta: currency, total, avgPrice
|
|
432
|
+
|
|
433
|
+
delay 200–600ms · persist ffa-data.json · errorRate 5%
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
---
|
|
437
|
+
|
|
438
|
+
## Config Validation (v0.11.0)
|
|
439
|
+
|
|
440
|
+
On startup, ffa checks your config and prints warnings:
|
|
399
441
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
| `.max(n)` | `string`, `number` | Max length / max value |
|
|
406
|
-
| `.readonly()` | all | Excluded from create/update validation |
|
|
407
|
-
| `.fake(hint)` | `string`, `number` | Explicit faker hint (see DSL hints above) |
|
|
442
|
+
```
|
|
443
|
+
⚠ Post.authorId (belongsTo) references 'Author' which is not defined
|
|
444
|
+
⚠ Tag.type is enum but has no values
|
|
445
|
+
⚠ User: seed has 5 records but count is 3 — seed will be truncated
|
|
446
|
+
```
|
|
408
447
|
|
|
409
448
|
---
|
|
410
449
|
|
|
411
|
-
##
|
|
450
|
+
## Error Simulation (v0.14.0)
|
|
451
|
+
|
|
452
|
+
Test your frontend error handling without mocking:
|
|
412
453
|
|
|
413
454
|
```ts
|
|
414
455
|
server: {
|
|
415
|
-
|
|
416
|
-
delay: 400, // fixed delay in ms
|
|
417
|
-
delay: [200, 800], // random delay between min and max ms
|
|
418
|
-
persist: true, // save to ./ffa-data.json
|
|
419
|
-
persist: './data.json', // custom file path
|
|
456
|
+
errorRate: 0.1 // 10% of requests return 500
|
|
420
457
|
}
|
|
421
458
|
```
|
|
422
459
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
If the configured port is busy, ffa automatically tries the next one.
|
|
460
|
+
System routes (`/__reset`, `/__snapshot`, `/docs`) are never affected.
|
|
426
461
|
|
|
427
462
|
---
|
|
428
463
|
|
|
429
|
-
##
|
|
430
|
-
|
|
431
|
-
| Method | Path | Description |
|
|
432
|
-
|--------|----------------|------------------------------------|
|
|
433
|
-
| `POST` | `/__reset` | Regenerate all seed data in memory |
|
|
434
|
-
| `GET` | `/docs` | Swagger UI |
|
|
435
|
-
| `GET` | `/openapi.json`| Raw OpenAPI 3.0 spec |
|
|
464
|
+
## Persistence
|
|
436
465
|
|
|
437
466
|
```ts
|
|
438
|
-
|
|
439
|
-
|
|
467
|
+
server: {
|
|
468
|
+
persist: true, // save to ./ffa-data.json
|
|
469
|
+
persist: './data/db.json', // custom path
|
|
470
|
+
}
|
|
440
471
|
```
|
|
441
472
|
|
|
473
|
+
Data is saved on every write (POST/PUT/PATCH/DELETE) and restored on restart.
|
|
474
|
+
|
|
475
|
+
---
|
|
476
|
+
|
|
477
|
+
## JSON Config (no TypeScript)
|
|
478
|
+
|
|
479
|
+
```json
|
|
480
|
+
{
|
|
481
|
+
"server": { "port": 3333, "delay": 300 },
|
|
482
|
+
"entities": {
|
|
483
|
+
"Product": {
|
|
484
|
+
"count": 20,
|
|
485
|
+
"fields": {
|
|
486
|
+
"title": "string!",
|
|
487
|
+
"price": "number",
|
|
488
|
+
"status": ["draft", "published", "archived"],
|
|
489
|
+
"inStock": "boolean",
|
|
490
|
+
"createdAt": "datetime"
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
Save as `ffa.config.json`. `"string!"` = required, `"string"` = optional. Arrays are shorthand for `enumField`.
|
|
498
|
+
|
|
442
499
|
---
|
|
443
500
|
|
|
444
501
|
## Terminal Output
|
|
445
502
|
|
|
446
|
-
|
|
503
|
+
```
|
|
504
|
+
FFA dev server v0.16.0
|
|
505
|
+
|
|
506
|
+
Entity Count Route
|
|
507
|
+
──────────────────────────────────────────────────────────
|
|
508
|
+
User 20 GET POST http://localhost:3333/users
|
|
509
|
+
Post 50 GET POST http://localhost:3333/posts
|
|
510
|
+
Tag 15 GET POST http://localhost:3333/tags
|
|
511
|
+
|
|
512
|
+
delay 200–600ms
|
|
513
|
+
errorRate 5% chance of 500
|
|
514
|
+
|
|
515
|
+
docs http://localhost:3333/docs
|
|
516
|
+
reset POST http://localhost:3333/__reset
|
|
447
517
|
|
|
518
|
+
GET /users 200 14ms
|
|
519
|
+
POST /posts 201 312ms
|
|
520
|
+
GET /posts/abc-123 404 8ms
|
|
521
|
+
PATCH /posts/def-456 200 267ms
|
|
448
522
|
```
|
|
449
|
-
FFA dev server v0.6.0
|
|
450
523
|
|
|
451
|
-
|
|
452
|
-
→ User 10 records http://localhost:3333/users
|
|
524
|
+
---
|
|
453
525
|
|
|
454
|
-
|
|
455
|
-
→ Swagger UI http://localhost:3333/docs
|
|
526
|
+
## Testing
|
|
456
527
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
PATCH /products/def-456 200 344ms
|
|
528
|
+
```bash
|
|
529
|
+
npm test # run all tests once
|
|
530
|
+
npm run test:watch # watch mode
|
|
461
531
|
```
|
|
462
532
|
|
|
533
|
+
Tests live in `tests/` and are excluded from the build. 144 tests covering:
|
|
534
|
+
|
|
535
|
+
- DSL builders and all fake hints (`string()`, `number()`, `object()`, `array()`, ...)
|
|
536
|
+
- Zod schema generation for all field types
|
|
537
|
+
- `entity()` helper
|
|
538
|
+
- `createMemoryDB` — CRUD, pagination, sorting, search, filter operators, seed, meta, relations, image dimensions
|
|
539
|
+
- `validateConfig` — all warning scenarios
|
|
540
|
+
|
|
463
541
|
---
|
|
464
542
|
|
|
465
543
|
## License
|