@koltakov/ffa-core 0.8.0 → 0.16.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 +358 -300
- package/dist/cli.js +250 -36
- package/dist/index.d.ts +12 -3
- package/dist/index.js +12 -2
- 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,339 +123,399 @@ 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
|
-
})
|
|
216
|
-
```
|
|
217
|
-
|
|
218
|
-
Response when filtering with `?status=active&page=1&limit=10`:
|
|
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
|
-
}
|
|
198
|
+
string() // chainable hints — see below
|
|
199
|
+
number() // chainable hints — see below
|
|
200
|
+
boolean()
|
|
201
|
+
uuid()
|
|
202
|
+
datetime()
|
|
234
203
|
```
|
|
235
204
|
|
|
236
|
-
|
|
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
|
-
##
|
|
241
|
+
## String Fake Hints
|
|
280
242
|
|
|
281
|
-
|
|
282
|
-
Hints take priority over smart field-name detection.
|
|
283
|
-
|
|
284
|
-
### `string()` hints
|
|
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().avatar() //
|
|
254
|
+
string().image() // "https://loremflickr.com/640/480"
|
|
255
|
+
string().avatar() // avatar URL
|
|
297
256
|
|
|
298
257
|
// Person
|
|
299
|
-
string().firstName() //
|
|
300
|
-
string().lastName() //
|
|
301
|
-
string().fullName() //
|
|
302
|
-
string().phone() //
|
|
258
|
+
string().firstName() // "Alice"
|
|
259
|
+
string().lastName() // "Johnson"
|
|
260
|
+
string().fullName() // "Alice Johnson"
|
|
261
|
+
string().phone() // "+1-555-234-5678"
|
|
303
262
|
|
|
304
263
|
// Location
|
|
305
|
-
string().city() //
|
|
306
|
-
string().country() //
|
|
307
|
-
string().address() //
|
|
308
|
-
string().zip() //
|
|
309
|
-
string().locale() //
|
|
264
|
+
string().city() // "Berlin"
|
|
265
|
+
string().country() // "Germany"
|
|
266
|
+
string().address() // "12 Oak Street"
|
|
267
|
+
string().zip() // "10115"
|
|
268
|
+
string().locale() // "DE"
|
|
310
269
|
|
|
311
270
|
// Business
|
|
312
|
-
string().company() //
|
|
313
|
-
string().jobTitle() //
|
|
314
|
-
string().department() //
|
|
315
|
-
string().currency() //
|
|
271
|
+
string().company() // "Acme Corp"
|
|
272
|
+
string().jobTitle() // "Senior Engineer"
|
|
273
|
+
string().department() // "Electronics"
|
|
274
|
+
string().currency() // "EUR"
|
|
316
275
|
|
|
317
276
|
// Text
|
|
318
|
-
string().word() //
|
|
319
|
-
string().slug() //
|
|
320
|
-
string().sentence() //
|
|
321
|
-
string().paragraph() //
|
|
322
|
-
string().bio() //
|
|
277
|
+
string().word() // "matrix"
|
|
278
|
+
string().slug() // "hello-world"
|
|
279
|
+
string().sentence() // "The quick brown fox."
|
|
280
|
+
string().paragraph() // "Lorem ipsum dolor..."
|
|
281
|
+
string().bio() // "Lorem ipsum dolor..."
|
|
323
282
|
|
|
324
283
|
// Visual
|
|
325
|
-
string().color() //
|
|
326
|
-
string().hexColor() //
|
|
284
|
+
string().color() // "azure"
|
|
285
|
+
string().hexColor() // "#A3F5C2"
|
|
327
286
|
|
|
328
287
|
// Id
|
|
329
|
-
string().uuid() //
|
|
288
|
+
string().uuid() // UUID v4
|
|
330
289
|
```
|
|
331
290
|
|
|
332
|
-
|
|
291
|
+
## Number Fake Hints
|
|
333
292
|
|
|
334
293
|
```ts
|
|
335
|
-
number().price() //
|
|
336
|
-
number().age() //
|
|
337
|
-
number().rating() //
|
|
338
|
-
number().percent() //
|
|
339
|
-
number().lat() //
|
|
340
|
-
number().lng() //
|
|
341
|
-
number().year() //
|
|
294
|
+
number().price() // 19.99
|
|
295
|
+
number().age() // 34
|
|
296
|
+
number().rating() // 4
|
|
297
|
+
number().percent() // 72
|
|
298
|
+
number().lat() // 51.5074
|
|
299
|
+
number().lng() // -0.1278
|
|
300
|
+
number().year() // 2021
|
|
342
301
|
```
|
|
343
302
|
|
|
344
|
-
|
|
303
|
+
## Smart Field-Name Detection
|
|
304
|
+
|
|
305
|
+
When no hint is set, ffa infers the right value from the field name automatically:
|
|
306
|
+
|
|
307
|
+
| Field name pattern | Generated value |
|
|
308
|
+
|--------------------|----------------|
|
|
309
|
+
| `email`, `mail` | Email address |
|
|
310
|
+
| `name`, `firstName` | First name |
|
|
311
|
+
| `lastName`, `surname` | Last name |
|
|
312
|
+
| `phone`, `tel`, `mobile` | Phone number |
|
|
313
|
+
| `city`, `country`, `address` | Location values |
|
|
314
|
+
| `url`, `website`, `link` | URL |
|
|
315
|
+
| `avatar`, `photo` | Avatar URL |
|
|
316
|
+
| `image` | Image URL |
|
|
317
|
+
| `company` | Company name |
|
|
318
|
+
| `title`, `heading` | Short sentence |
|
|
319
|
+
| `description`, `bio`, `text` | Paragraph |
|
|
320
|
+
| `price`, `cost`, `amount` | Price |
|
|
321
|
+
| `color` | Color name |
|
|
345
322
|
|
|
346
|
-
|
|
323
|
+
---
|
|
324
|
+
|
|
325
|
+
## Field Rules
|
|
326
|
+
|
|
327
|
+
All builders share these chainable rules:
|
|
328
|
+
|
|
329
|
+
| Method | Effect |
|
|
330
|
+
|--------|--------|
|
|
331
|
+
| `.required()` | Field required in POST/PUT |
|
|
332
|
+
| `.optional()` | Field can be omitted (default) |
|
|
333
|
+
| `.min(n)` | Min string length / min number value |
|
|
334
|
+
| `.max(n)` | Max string length / max number value |
|
|
335
|
+
| `.readonly()` | Excluded from create/update validation |
|
|
336
|
+
| `.default(val)` | Default value |
|
|
337
|
+
| `.fake(hint)` | Explicit faker hint |
|
|
338
|
+
|
|
339
|
+
---
|
|
340
|
+
|
|
341
|
+
## Seed Data (v0.9.0)
|
|
342
|
+
|
|
343
|
+
Guarantee specific records always exist in your entity:
|
|
347
344
|
|
|
348
345
|
```ts
|
|
349
|
-
|
|
350
|
-
|
|
346
|
+
User: entity({
|
|
347
|
+
name: string().fullName().required(),
|
|
348
|
+
role: enumField(['admin', 'editor', 'viewer']).required(),
|
|
349
|
+
}, {
|
|
350
|
+
count: 20,
|
|
351
|
+
seed: [
|
|
352
|
+
{ id: 'admin-1', name: 'Admin User', role: 'admin' },
|
|
353
|
+
{ name: 'Guest User', role: 'viewer' }, // id auto-generated if omitted
|
|
354
|
+
],
|
|
355
|
+
})
|
|
351
356
|
```
|
|
352
357
|
|
|
353
|
-
|
|
358
|
+
Seed records appear first. The remaining `count - seed.length` records are generated.
|
|
359
|
+
|
|
360
|
+
---
|
|
361
|
+
|
|
362
|
+
## Custom Meta (v0.8.0)
|
|
363
|
+
|
|
364
|
+
Attach extra fields to the list response `meta`. Values can be static or computed from the current filtered dataset (before pagination):
|
|
354
365
|
|
|
355
366
|
```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 |
|
|
367
|
+
Product: entity({ price: number().price(), status: enumField(['active', 'sale']) }, {
|
|
368
|
+
count: 50,
|
|
369
|
+
meta: {
|
|
370
|
+
currency: 'USD', // static
|
|
371
|
+
apiVersion: 2, // static
|
|
372
|
+
total: (items) => items.length, // computed
|
|
373
|
+
avgPrice: (items) => +(items.reduce((s, i) => s + (i.price as number), 0) / items.length).toFixed(2),
|
|
374
|
+
byStatus: (items) => Object.fromEntries(
|
|
375
|
+
['active', 'sale'].map(s => [s, items.filter(i => i.status === s).length])
|
|
376
|
+
),
|
|
377
|
+
},
|
|
378
|
+
})
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
Meta functions receive items **after** search/filter but **before** pagination — so aggregates always reflect the current query.
|
|
395
382
|
|
|
396
383
|
---
|
|
397
384
|
|
|
398
|
-
##
|
|
385
|
+
## Seed Data Export (v0.15.0)
|
|
386
|
+
|
|
387
|
+
Export the current in-memory DB while the server is running — useful for generating fixture files:
|
|
399
388
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
389
|
+
```bash
|
|
390
|
+
ffa snapshot # → ffa-snapshot.json
|
|
391
|
+
ffa snapshot -o fixtures.json
|
|
392
|
+
ffa snapshot -p 4000 # specify port if not in config
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
Or call directly:
|
|
396
|
+
```
|
|
397
|
+
GET /__snapshot → { "User": [...], "Post": [...] }
|
|
398
|
+
```
|
|
408
399
|
|
|
409
400
|
---
|
|
410
401
|
|
|
411
|
-
##
|
|
402
|
+
## Inspect Config (v0.16.0)
|
|
403
|
+
|
|
404
|
+
Print your config structure without starting the server:
|
|
405
|
+
|
|
406
|
+
```bash
|
|
407
|
+
ffa inspect
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
```
|
|
411
|
+
FFA inspect port 3333
|
|
412
|
+
|
|
413
|
+
User 20 records +1 seed
|
|
414
|
+
* name string [fullName]
|
|
415
|
+
* email string [email]
|
|
416
|
+
* role enum (admin | editor | viewer)
|
|
417
|
+
avatar string [avatar]
|
|
418
|
+
address object { city, country, zip }
|
|
419
|
+
phones array of string
|
|
420
|
+
|
|
421
|
+
Post 50 records
|
|
422
|
+
* title string [sentence]
|
|
423
|
+
* body string [paragraph]
|
|
424
|
+
* status enum (draft | published | archived)
|
|
425
|
+
* price number [price]
|
|
426
|
+
authorId belongsTo → User
|
|
427
|
+
tagIds hasMany → Tag
|
|
428
|
+
meta: currency, total, avgPrice
|
|
429
|
+
|
|
430
|
+
delay 200–600ms · persist ffa-data.json · errorRate 5%
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
---
|
|
434
|
+
|
|
435
|
+
## Config Validation (v0.11.0)
|
|
436
|
+
|
|
437
|
+
On startup, ffa checks your config and prints warnings:
|
|
438
|
+
|
|
439
|
+
```
|
|
440
|
+
⚠ Post.authorId (belongsTo) references 'Author' which is not defined
|
|
441
|
+
⚠ Tag.type is enum but has no values
|
|
442
|
+
⚠ User: seed has 5 records but count is 3 — seed will be truncated
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
---
|
|
446
|
+
|
|
447
|
+
## Error Simulation (v0.14.0)
|
|
448
|
+
|
|
449
|
+
Test your frontend error handling without mocking:
|
|
412
450
|
|
|
413
451
|
```ts
|
|
414
452
|
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
|
|
453
|
+
errorRate: 0.1 // 10% of requests return 500
|
|
420
454
|
}
|
|
421
455
|
```
|
|
422
456
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
If the configured port is busy, ffa automatically tries the next one.
|
|
457
|
+
System routes (`/__reset`, `/__snapshot`, `/docs`) are never affected.
|
|
426
458
|
|
|
427
459
|
---
|
|
428
460
|
|
|
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 |
|
|
461
|
+
## Persistence
|
|
436
462
|
|
|
437
463
|
```ts
|
|
438
|
-
|
|
439
|
-
|
|
464
|
+
server: {
|
|
465
|
+
persist: true, // save to ./ffa-data.json
|
|
466
|
+
persist: './data/db.json', // custom path
|
|
467
|
+
}
|
|
440
468
|
```
|
|
441
469
|
|
|
470
|
+
Data is saved on every write (POST/PUT/PATCH/DELETE) and restored on restart.
|
|
471
|
+
|
|
442
472
|
---
|
|
443
473
|
|
|
444
|
-
##
|
|
474
|
+
## JSON Config (no TypeScript)
|
|
445
475
|
|
|
446
|
-
|
|
476
|
+
```json
|
|
477
|
+
{
|
|
478
|
+
"server": { "port": 3333, "delay": 300 },
|
|
479
|
+
"entities": {
|
|
480
|
+
"Product": {
|
|
481
|
+
"count": 20,
|
|
482
|
+
"fields": {
|
|
483
|
+
"title": "string!",
|
|
484
|
+
"price": "number",
|
|
485
|
+
"status": ["draft", "published", "archived"],
|
|
486
|
+
"inStock": "boolean",
|
|
487
|
+
"createdAt": "datetime"
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
Save as `ffa.config.json`. `"string!"` = required, `"string"` = optional. Arrays are shorthand for `enumField`.
|
|
495
|
+
|
|
496
|
+
---
|
|
497
|
+
|
|
498
|
+
## Terminal Output
|
|
447
499
|
|
|
448
500
|
```
|
|
449
|
-
FFA dev server v0.
|
|
501
|
+
FFA dev server v0.16.0
|
|
502
|
+
|
|
503
|
+
Entity Count Route
|
|
504
|
+
──────────────────────────────────────────────────────────
|
|
505
|
+
User 20 GET POST http://localhost:3333/users
|
|
506
|
+
Post 50 GET POST http://localhost:3333/posts
|
|
507
|
+
Tag 15 GET POST http://localhost:3333/tags
|
|
450
508
|
|
|
451
|
-
|
|
452
|
-
|
|
509
|
+
delay 200–600ms
|
|
510
|
+
errorRate 5% chance of 500
|
|
453
511
|
|
|
454
|
-
|
|
455
|
-
|
|
512
|
+
docs http://localhost:3333/docs
|
|
513
|
+
reset POST http://localhost:3333/__reset
|
|
456
514
|
|
|
457
|
-
GET /
|
|
458
|
-
POST /
|
|
459
|
-
GET /
|
|
460
|
-
PATCH /
|
|
515
|
+
GET /users 200 14ms
|
|
516
|
+
POST /posts 201 312ms
|
|
517
|
+
GET /posts/abc-123 404 8ms
|
|
518
|
+
PATCH /posts/def-456 200 267ms
|
|
461
519
|
```
|
|
462
520
|
|
|
463
521
|
---
|