@aggiovato/yrest 0.1.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 +169 -0
- package/dist/cli/index.d.mts +1 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +314 -0
- package/dist/cli/index.mjs +291 -0
- package/dist/index.d.mts +38 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.js +212 -0
- package/dist/index.mjs +173 -0
- package/package.json +59 -0
package/README.md
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# yrest
|
|
2
|
+
|
|
3
|
+
Zero-config REST API mock server powered by a YAML file.
|
|
4
|
+
|
|
5
|
+
Define your data in a `db.yml` file and get a fully functional CRUD REST API in seconds — no backend required.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -D yrest
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or run directly with npx:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx yrest serve db.yml
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quick start
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# Create a sample db.yml in the current directory
|
|
23
|
+
npx yrest init
|
|
24
|
+
|
|
25
|
+
# Start the server
|
|
26
|
+
npx yrest serve db.yml
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```txt
|
|
30
|
+
yrest running at http://localhost:3070
|
|
31
|
+
|
|
32
|
+
Resources (base: /):
|
|
33
|
+
/users
|
|
34
|
+
/posts
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Commands
|
|
38
|
+
|
|
39
|
+
### `init`
|
|
40
|
+
|
|
41
|
+
Creates a sample `db.yml` in the current directory.
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
yrest init # basic sample (default)
|
|
45
|
+
yrest init --sample relational # with _rel relations
|
|
46
|
+
yrest init --file api.yml # custom filename
|
|
47
|
+
yrest init --sample relational --file api.yml
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
| Flag | Default | Description |
|
|
51
|
+
|------|---------|-------------|
|
|
52
|
+
| `--file` | `db.yml` | Output filename |
|
|
53
|
+
| `--sample` | `basic` | Sample data (`basic`, `relational`) |
|
|
54
|
+
|
|
55
|
+
**Samples:**
|
|
56
|
+
- `basic` — two independent collections: `users` and `products`
|
|
57
|
+
- `relational` — three collections with `_rel` relationships: `users`, `posts` and `comments`
|
|
58
|
+
|
|
59
|
+
### `serve`
|
|
60
|
+
|
|
61
|
+
Starts the mock server.
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
yrest serve db.yml
|
|
65
|
+
yrest serve db.yml --port 3001
|
|
66
|
+
yrest serve db.yml --host 0.0.0.0
|
|
67
|
+
yrest serve db.yml --base /api
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
| Flag | Default | Description |
|
|
71
|
+
|------|---------|-------------|
|
|
72
|
+
| `--port` | `3070` | Port to listen on |
|
|
73
|
+
| `--host` | `localhost` | Host to bind |
|
|
74
|
+
| `--base` | _(none)_ | Prefix for all routes |
|
|
75
|
+
|
|
76
|
+
## Database format
|
|
77
|
+
|
|
78
|
+
```yaml
|
|
79
|
+
users:
|
|
80
|
+
- id: 1
|
|
81
|
+
name: Ana
|
|
82
|
+
email: ana@test.com
|
|
83
|
+
- id: 2
|
|
84
|
+
name: Luis
|
|
85
|
+
email: luis@test.com
|
|
86
|
+
|
|
87
|
+
posts:
|
|
88
|
+
- id: 1
|
|
89
|
+
title: First post
|
|
90
|
+
userId: 1
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Each top-level key becomes a resource with full CRUD endpoints.
|
|
94
|
+
|
|
95
|
+
## Generated endpoints
|
|
96
|
+
|
|
97
|
+
For each resource in `db.yml`:
|
|
98
|
+
|
|
99
|
+
```txt
|
|
100
|
+
GET /users List all
|
|
101
|
+
GET /users/:id Get one
|
|
102
|
+
POST /users Create
|
|
103
|
+
PUT /users/:id Replace
|
|
104
|
+
PATCH /users/:id Partial update
|
|
105
|
+
DELETE /users/:id Delete
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
With `--base /api` all routes are prefixed: `/api/users`, `/api/users/:id`, etc.
|
|
109
|
+
|
|
110
|
+
## HTTP responses
|
|
111
|
+
|
|
112
|
+
| Status | When |
|
|
113
|
+
|--------|------|
|
|
114
|
+
| `200` | Successful GET, PUT, PATCH, DELETE |
|
|
115
|
+
| `201` | Successful POST |
|
|
116
|
+
| `404` | Resource or id not found |
|
|
117
|
+
| `500` | Error reading or writing the YAML file |
|
|
118
|
+
|
|
119
|
+
DELETE returns the deleted item (useful for debugging).
|
|
120
|
+
|
|
121
|
+
## ID generation
|
|
122
|
+
|
|
123
|
+
If a POST body does not include an `id`, yrest assigns the next incremental integer automatically. If the body includes an `id`, it is respected.
|
|
124
|
+
|
|
125
|
+
## Persistence
|
|
126
|
+
|
|
127
|
+
All write operations (POST, PUT, PATCH, DELETE) are saved back to `db.yml` immediately using an atomic write strategy (write to temp file → rename), so data is never corrupted even if the process is interrupted.
|
|
128
|
+
|
|
129
|
+
## CORS
|
|
130
|
+
|
|
131
|
+
CORS is enabled by default, so you can call the API from any frontend running on a different port without extra configuration.
|
|
132
|
+
|
|
133
|
+
## Frontend usage
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
// List
|
|
137
|
+
const users = await fetch("http://localhost:3070/users").then(r => r.json());
|
|
138
|
+
|
|
139
|
+
// Create
|
|
140
|
+
await fetch("http://localhost:3070/users", {
|
|
141
|
+
method: "POST",
|
|
142
|
+
headers: { "Content-Type": "application/json" },
|
|
143
|
+
body: JSON.stringify({ name: "Carlos", email: "carlos@test.com" }),
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// Partial update
|
|
147
|
+
await fetch("http://localhost:3070/users/1", {
|
|
148
|
+
method: "PATCH",
|
|
149
|
+
headers: { "Content-Type": "application/json" },
|
|
150
|
+
body: JSON.stringify({ name: "Ana Updated" }),
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// Delete
|
|
154
|
+
await fetch("http://localhost:3070/users/1", { method: "DELETE" });
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Use in package.json scripts
|
|
158
|
+
|
|
159
|
+
```json
|
|
160
|
+
{
|
|
161
|
+
"scripts": {
|
|
162
|
+
"mock": "yrest serve db.yml"
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## License
|
|
168
|
+
|
|
169
|
+
MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __create = Object.create;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __copyProps = (to, from, except, desc) => {
|
|
10
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
11
|
+
for (let key of __getOwnPropNames(from))
|
|
12
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
13
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
14
|
+
}
|
|
15
|
+
return to;
|
|
16
|
+
};
|
|
17
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
18
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
19
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
20
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
21
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
22
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
23
|
+
mod
|
|
24
|
+
));
|
|
25
|
+
|
|
26
|
+
// src/cli/index.ts
|
|
27
|
+
var import_commander = require("commander");
|
|
28
|
+
|
|
29
|
+
// src/cli/commands/init.ts
|
|
30
|
+
var import_node_fs = require("fs");
|
|
31
|
+
var import_node_path = require("path");
|
|
32
|
+
|
|
33
|
+
// src/cli/commands/templates/basic.ts
|
|
34
|
+
var basicTemplate = `users:
|
|
35
|
+
- id: 1
|
|
36
|
+
name: Ana
|
|
37
|
+
email: ana@test.com
|
|
38
|
+
- id: 2
|
|
39
|
+
name: Luis
|
|
40
|
+
email: luis@test.com
|
|
41
|
+
|
|
42
|
+
products:
|
|
43
|
+
- id: 1
|
|
44
|
+
name: Laptop
|
|
45
|
+
price: 999
|
|
46
|
+
- id: 2
|
|
47
|
+
name: Phone
|
|
48
|
+
price: 499
|
|
49
|
+
`;
|
|
50
|
+
|
|
51
|
+
// src/cli/commands/templates/relational.ts
|
|
52
|
+
var relationalTemplate = `_rel:
|
|
53
|
+
posts:
|
|
54
|
+
userId: users
|
|
55
|
+
comments:
|
|
56
|
+
postId: posts
|
|
57
|
+
|
|
58
|
+
users:
|
|
59
|
+
- id: 1
|
|
60
|
+
name: Ana
|
|
61
|
+
email: ana@test.com
|
|
62
|
+
- id: 2
|
|
63
|
+
name: Luis
|
|
64
|
+
email: luis@test.com
|
|
65
|
+
|
|
66
|
+
posts:
|
|
67
|
+
- id: 1
|
|
68
|
+
title: First post
|
|
69
|
+
body: Content of the first post
|
|
70
|
+
userId: 1
|
|
71
|
+
- id: 2
|
|
72
|
+
title: Second post
|
|
73
|
+
body: Content of the second post
|
|
74
|
+
userId: 1
|
|
75
|
+
|
|
76
|
+
comments:
|
|
77
|
+
- id: 1
|
|
78
|
+
body: Great post!
|
|
79
|
+
postId: 1
|
|
80
|
+
- id: 2
|
|
81
|
+
body: Thanks for sharing
|
|
82
|
+
postId: 1
|
|
83
|
+
`;
|
|
84
|
+
|
|
85
|
+
// src/cli/commands/templates/index.ts
|
|
86
|
+
var SAMPLES = ["basic", "relational"];
|
|
87
|
+
var templates = {
|
|
88
|
+
basic: basicTemplate,
|
|
89
|
+
relational: relationalTemplate
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// src/cli/commands/init.ts
|
|
93
|
+
function registerInit(program2) {
|
|
94
|
+
program2.command("init").description("Create a sample db.yml in the current directory").option("-f, --file <name>", "Output filename", "db.yml").option(
|
|
95
|
+
"-s, --sample <name>",
|
|
96
|
+
`Sample data to use (${SAMPLES.join(", ")})`,
|
|
97
|
+
"basic"
|
|
98
|
+
).action((flags) => {
|
|
99
|
+
if (!SAMPLES.includes(flags.sample)) {
|
|
100
|
+
console.error(
|
|
101
|
+
`Error: unknown sample "${flags.sample}". Available: ${SAMPLES.join(", ")}`
|
|
102
|
+
);
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
const target = (0, import_node_path.resolve)(process.cwd(), flags.file);
|
|
106
|
+
if ((0, import_node_fs.existsSync)(target)) {
|
|
107
|
+
console.error(`Error: ${flags.file} already exists.`);
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
(0, import_node_fs.writeFileSync)(target, templates[flags.sample], "utf8");
|
|
111
|
+
console.log(`Created ${flags.file} (sample: ${flags.sample})`);
|
|
112
|
+
console.log(`Run: yrest serve ${flags.file}`);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// src/storage/yamlStorage.ts
|
|
117
|
+
var import_node_fs2 = require("fs");
|
|
118
|
+
var import_node_path2 = require("path");
|
|
119
|
+
var import_node_crypto = require("crypto");
|
|
120
|
+
var import_yaml = require("yaml");
|
|
121
|
+
function createYamlStorage(filePath) {
|
|
122
|
+
const absPath = (0, import_node_path2.resolve)(filePath);
|
|
123
|
+
const raw = (0, import_yaml.parse)((0, import_node_fs2.readFileSync)(absPath, "utf8")) ?? {};
|
|
124
|
+
const relations = raw["_rel"] ?? {};
|
|
125
|
+
const data = Object.fromEntries(
|
|
126
|
+
Object.entries(raw).filter(([key]) => key !== "_rel")
|
|
127
|
+
);
|
|
128
|
+
return {
|
|
129
|
+
getData() {
|
|
130
|
+
return data;
|
|
131
|
+
},
|
|
132
|
+
getRelations() {
|
|
133
|
+
return relations;
|
|
134
|
+
},
|
|
135
|
+
getCollection(name) {
|
|
136
|
+
return data[name];
|
|
137
|
+
},
|
|
138
|
+
setCollection(name, items) {
|
|
139
|
+
data[name] = items;
|
|
140
|
+
},
|
|
141
|
+
persist() {
|
|
142
|
+
const payload = Object.keys(relations).length > 0 ? { _rel: relations, ...data } : { ...data };
|
|
143
|
+
const tmp = (0, import_node_path2.resolve)((0, import_node_path2.dirname)(absPath), `.yrest-${(0, import_node_crypto.randomUUID)()}.tmp`);
|
|
144
|
+
(0, import_node_fs2.writeFileSync)(tmp, (0, import_yaml.stringify)(payload), "utf8");
|
|
145
|
+
(0, import_node_fs2.renameSync)(tmp, absPath);
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// src/server/createServer.ts
|
|
151
|
+
var import_fastify = __toESM(require("fastify"));
|
|
152
|
+
var import_cors = __toESM(require("@fastify/cors"));
|
|
153
|
+
|
|
154
|
+
// src/services/resourceService.ts
|
|
155
|
+
function nextId(items) {
|
|
156
|
+
const ids = items.map((i) => i["id"]).filter((id) => typeof id === "number");
|
|
157
|
+
return ids.length > 0 ? Math.max(...ids) + 1 : 1;
|
|
158
|
+
}
|
|
159
|
+
function findById(items, id) {
|
|
160
|
+
return items.find((i) => String(i["id"]) === id);
|
|
161
|
+
}
|
|
162
|
+
function findIndexById(items, id) {
|
|
163
|
+
return items.findIndex((i) => String(i["id"]) === id);
|
|
164
|
+
}
|
|
165
|
+
function createItem(storage, resource, body) {
|
|
166
|
+
const collection = storage.getCollection(resource) ?? [];
|
|
167
|
+
const item = {
|
|
168
|
+
id: body["id"] !== void 0 ? body["id"] : nextId(collection),
|
|
169
|
+
...body
|
|
170
|
+
};
|
|
171
|
+
storage.setCollection(resource, [...collection, item]);
|
|
172
|
+
storage.persist();
|
|
173
|
+
return item;
|
|
174
|
+
}
|
|
175
|
+
function replaceItem(storage, resource, id, body) {
|
|
176
|
+
const collection = storage.getCollection(resource) ?? [];
|
|
177
|
+
const idx = findIndexById(collection, id);
|
|
178
|
+
if (idx === -1) return void 0;
|
|
179
|
+
const updated = { ...body, id: collection[idx]["id"] };
|
|
180
|
+
collection[idx] = updated;
|
|
181
|
+
storage.setCollection(resource, collection);
|
|
182
|
+
storage.persist();
|
|
183
|
+
return updated;
|
|
184
|
+
}
|
|
185
|
+
function patchItem(storage, resource, id, body) {
|
|
186
|
+
const collection = storage.getCollection(resource) ?? [];
|
|
187
|
+
const idx = findIndexById(collection, id);
|
|
188
|
+
if (idx === -1) return void 0;
|
|
189
|
+
const updated = { ...collection[idx], ...body };
|
|
190
|
+
collection[idx] = updated;
|
|
191
|
+
storage.setCollection(resource, collection);
|
|
192
|
+
storage.persist();
|
|
193
|
+
return updated;
|
|
194
|
+
}
|
|
195
|
+
function deleteItem(storage, resource, id) {
|
|
196
|
+
const collection = storage.getCollection(resource) ?? [];
|
|
197
|
+
const idx = findIndexById(collection, id);
|
|
198
|
+
if (idx === -1) return void 0;
|
|
199
|
+
const [deleted] = collection.splice(idx, 1);
|
|
200
|
+
storage.setCollection(resource, collection);
|
|
201
|
+
storage.persist();
|
|
202
|
+
return deleted;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// src/router/routes/collection.routes.ts
|
|
206
|
+
function registerCollectionRoutes(server, storage, resource, prefix) {
|
|
207
|
+
server.get(prefix, () => storage.getCollection(resource) ?? []);
|
|
208
|
+
server.post(prefix, (req, reply) => {
|
|
209
|
+
const item = createItem(storage, resource, req.body);
|
|
210
|
+
return reply.status(201).send(item);
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// src/router/routes/item.routes.ts
|
|
215
|
+
function registerItemRoutes(server, storage, resource, prefix) {
|
|
216
|
+
server.get(`${prefix}/:id`, (req, reply) => {
|
|
217
|
+
const item = findById(storage.getCollection(resource) ?? [], req.params.id);
|
|
218
|
+
if (!item) return reply.status(404).send({ error: "Not found" });
|
|
219
|
+
return item;
|
|
220
|
+
});
|
|
221
|
+
server.put(`${prefix}/:id`, (req, reply) => {
|
|
222
|
+
const item = replaceItem(storage, resource, req.params.id, req.body);
|
|
223
|
+
if (!item) return reply.status(404).send({ error: "Not found" });
|
|
224
|
+
return item;
|
|
225
|
+
});
|
|
226
|
+
server.patch(`${prefix}/:id`, (req, reply) => {
|
|
227
|
+
const item = patchItem(storage, resource, req.params.id, req.body);
|
|
228
|
+
if (!item) return reply.status(404).send({ error: "Not found" });
|
|
229
|
+
return item;
|
|
230
|
+
});
|
|
231
|
+
server.delete(`${prefix}/:id`, (req, reply) => {
|
|
232
|
+
const item = deleteItem(storage, resource, req.params.id);
|
|
233
|
+
if (!item) return reply.status(404).send({ error: "Not found" });
|
|
234
|
+
return item;
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// src/router/routes/nested.routes.ts
|
|
239
|
+
function registerNestedRoutes(server, storage, relations, base) {
|
|
240
|
+
for (const [child, fields] of Object.entries(relations)) {
|
|
241
|
+
for (const [field, parent] of Object.entries(fields)) {
|
|
242
|
+
server.get(
|
|
243
|
+
`${base}/${parent}/:id/${child}`,
|
|
244
|
+
(req, reply) => {
|
|
245
|
+
const parentCollection = storage.getCollection(parent) ?? [];
|
|
246
|
+
const parentItem = findById(parentCollection, req.params.id);
|
|
247
|
+
if (!parentItem) return reply.status(404).send({ error: "Not found" });
|
|
248
|
+
const children = (storage.getCollection(child) ?? []).filter(
|
|
249
|
+
(item) => String(item[field]) === req.params.id
|
|
250
|
+
);
|
|
251
|
+
return children;
|
|
252
|
+
}
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// src/router/resource.router.ts
|
|
259
|
+
function registerResourceRoutes(server, storage, base) {
|
|
260
|
+
for (const resource of Object.keys(storage.getData())) {
|
|
261
|
+
const prefix = `${base}/${resource}`;
|
|
262
|
+
registerCollectionRoutes(server, storage, resource, prefix);
|
|
263
|
+
registerItemRoutes(server, storage, resource, prefix);
|
|
264
|
+
}
|
|
265
|
+
registerNestedRoutes(server, storage, storage.getRelations(), base);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// src/server/createServer.ts
|
|
269
|
+
async function createServer(storage, options) {
|
|
270
|
+
const server = (0, import_fastify.default)();
|
|
271
|
+
await server.register(import_cors.default);
|
|
272
|
+
registerResourceRoutes(server, storage, options.base);
|
|
273
|
+
return server;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// src/config/loadOptions.ts
|
|
277
|
+
var import_zod = require("zod");
|
|
278
|
+
var serverOptionsSchema = import_zod.z.object({
|
|
279
|
+
file: import_zod.z.string().min(1),
|
|
280
|
+
port: import_zod.z.coerce.number().int().positive().default(3070),
|
|
281
|
+
host: import_zod.z.string().default("localhost"),
|
|
282
|
+
base: import_zod.z.string().default("").transform((v) => v && !v.startsWith("/") ? `/${v}` : v)
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// src/cli/commands/serve.ts
|
|
286
|
+
function registerServe(program2) {
|
|
287
|
+
program2.command("serve").description("Start the mock server using a YAML file as database").argument("[file]", "Path to the YAML database file", "db.yml").option("-p, --port <number>", "Port to listen on", "3070").option("-H, --host <host>", "Host to bind", "localhost").option("-b, --base <path>", "Base path prefix for all routes", "").action(async (file, flags) => {
|
|
288
|
+
const options = serverOptionsSchema.parse({
|
|
289
|
+
file,
|
|
290
|
+
port: flags["port"],
|
|
291
|
+
host: flags["host"],
|
|
292
|
+
base: flags["base"]
|
|
293
|
+
});
|
|
294
|
+
const storage = createYamlStorage(options.file);
|
|
295
|
+
const server = await createServer(storage, options);
|
|
296
|
+
await server.listen({ port: options.port, host: options.host });
|
|
297
|
+
const collections = Object.keys(storage.getData());
|
|
298
|
+
const baseLabel = options.base || "/";
|
|
299
|
+
console.log(`
|
|
300
|
+
yrest running at http://${options.host}:${options.port}`);
|
|
301
|
+
console.log(`
|
|
302
|
+
Resources (base: ${baseLabel}):`);
|
|
303
|
+
for (const name of collections) {
|
|
304
|
+
console.log(` /${name}`);
|
|
305
|
+
}
|
|
306
|
+
console.log("");
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// src/cli/index.ts
|
|
311
|
+
import_commander.program.name("yrest").description("Zero-config REST API mock server powered by a YAML file").version("0.1.0");
|
|
312
|
+
registerInit(import_commander.program);
|
|
313
|
+
registerServe(import_commander.program);
|
|
314
|
+
import_commander.program.parse();
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli/index.ts
|
|
4
|
+
import { program } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/cli/commands/init.ts
|
|
7
|
+
import { existsSync, writeFileSync } from "fs";
|
|
8
|
+
import { resolve } from "path";
|
|
9
|
+
|
|
10
|
+
// src/cli/commands/templates/basic.ts
|
|
11
|
+
var basicTemplate = `users:
|
|
12
|
+
- id: 1
|
|
13
|
+
name: Ana
|
|
14
|
+
email: ana@test.com
|
|
15
|
+
- id: 2
|
|
16
|
+
name: Luis
|
|
17
|
+
email: luis@test.com
|
|
18
|
+
|
|
19
|
+
products:
|
|
20
|
+
- id: 1
|
|
21
|
+
name: Laptop
|
|
22
|
+
price: 999
|
|
23
|
+
- id: 2
|
|
24
|
+
name: Phone
|
|
25
|
+
price: 499
|
|
26
|
+
`;
|
|
27
|
+
|
|
28
|
+
// src/cli/commands/templates/relational.ts
|
|
29
|
+
var relationalTemplate = `_rel:
|
|
30
|
+
posts:
|
|
31
|
+
userId: users
|
|
32
|
+
comments:
|
|
33
|
+
postId: posts
|
|
34
|
+
|
|
35
|
+
users:
|
|
36
|
+
- id: 1
|
|
37
|
+
name: Ana
|
|
38
|
+
email: ana@test.com
|
|
39
|
+
- id: 2
|
|
40
|
+
name: Luis
|
|
41
|
+
email: luis@test.com
|
|
42
|
+
|
|
43
|
+
posts:
|
|
44
|
+
- id: 1
|
|
45
|
+
title: First post
|
|
46
|
+
body: Content of the first post
|
|
47
|
+
userId: 1
|
|
48
|
+
- id: 2
|
|
49
|
+
title: Second post
|
|
50
|
+
body: Content of the second post
|
|
51
|
+
userId: 1
|
|
52
|
+
|
|
53
|
+
comments:
|
|
54
|
+
- id: 1
|
|
55
|
+
body: Great post!
|
|
56
|
+
postId: 1
|
|
57
|
+
- id: 2
|
|
58
|
+
body: Thanks for sharing
|
|
59
|
+
postId: 1
|
|
60
|
+
`;
|
|
61
|
+
|
|
62
|
+
// src/cli/commands/templates/index.ts
|
|
63
|
+
var SAMPLES = ["basic", "relational"];
|
|
64
|
+
var templates = {
|
|
65
|
+
basic: basicTemplate,
|
|
66
|
+
relational: relationalTemplate
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// src/cli/commands/init.ts
|
|
70
|
+
function registerInit(program2) {
|
|
71
|
+
program2.command("init").description("Create a sample db.yml in the current directory").option("-f, --file <name>", "Output filename", "db.yml").option(
|
|
72
|
+
"-s, --sample <name>",
|
|
73
|
+
`Sample data to use (${SAMPLES.join(", ")})`,
|
|
74
|
+
"basic"
|
|
75
|
+
).action((flags) => {
|
|
76
|
+
if (!SAMPLES.includes(flags.sample)) {
|
|
77
|
+
console.error(
|
|
78
|
+
`Error: unknown sample "${flags.sample}". Available: ${SAMPLES.join(", ")}`
|
|
79
|
+
);
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
const target = resolve(process.cwd(), flags.file);
|
|
83
|
+
if (existsSync(target)) {
|
|
84
|
+
console.error(`Error: ${flags.file} already exists.`);
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
writeFileSync(target, templates[flags.sample], "utf8");
|
|
88
|
+
console.log(`Created ${flags.file} (sample: ${flags.sample})`);
|
|
89
|
+
console.log(`Run: yrest serve ${flags.file}`);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// src/storage/yamlStorage.ts
|
|
94
|
+
import { readFileSync, writeFileSync as writeFileSync2, renameSync } from "fs";
|
|
95
|
+
import { resolve as resolve2, dirname } from "path";
|
|
96
|
+
import { randomUUID } from "crypto";
|
|
97
|
+
import { parse, stringify } from "yaml";
|
|
98
|
+
function createYamlStorage(filePath) {
|
|
99
|
+
const absPath = resolve2(filePath);
|
|
100
|
+
const raw = parse(readFileSync(absPath, "utf8")) ?? {};
|
|
101
|
+
const relations = raw["_rel"] ?? {};
|
|
102
|
+
const data = Object.fromEntries(
|
|
103
|
+
Object.entries(raw).filter(([key]) => key !== "_rel")
|
|
104
|
+
);
|
|
105
|
+
return {
|
|
106
|
+
getData() {
|
|
107
|
+
return data;
|
|
108
|
+
},
|
|
109
|
+
getRelations() {
|
|
110
|
+
return relations;
|
|
111
|
+
},
|
|
112
|
+
getCollection(name) {
|
|
113
|
+
return data[name];
|
|
114
|
+
},
|
|
115
|
+
setCollection(name, items) {
|
|
116
|
+
data[name] = items;
|
|
117
|
+
},
|
|
118
|
+
persist() {
|
|
119
|
+
const payload = Object.keys(relations).length > 0 ? { _rel: relations, ...data } : { ...data };
|
|
120
|
+
const tmp = resolve2(dirname(absPath), `.yrest-${randomUUID()}.tmp`);
|
|
121
|
+
writeFileSync2(tmp, stringify(payload), "utf8");
|
|
122
|
+
renameSync(tmp, absPath);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// src/server/createServer.ts
|
|
128
|
+
import Fastify from "fastify";
|
|
129
|
+
import cors from "@fastify/cors";
|
|
130
|
+
|
|
131
|
+
// src/services/resourceService.ts
|
|
132
|
+
function nextId(items) {
|
|
133
|
+
const ids = items.map((i) => i["id"]).filter((id) => typeof id === "number");
|
|
134
|
+
return ids.length > 0 ? Math.max(...ids) + 1 : 1;
|
|
135
|
+
}
|
|
136
|
+
function findById(items, id) {
|
|
137
|
+
return items.find((i) => String(i["id"]) === id);
|
|
138
|
+
}
|
|
139
|
+
function findIndexById(items, id) {
|
|
140
|
+
return items.findIndex((i) => String(i["id"]) === id);
|
|
141
|
+
}
|
|
142
|
+
function createItem(storage, resource, body) {
|
|
143
|
+
const collection = storage.getCollection(resource) ?? [];
|
|
144
|
+
const item = {
|
|
145
|
+
id: body["id"] !== void 0 ? body["id"] : nextId(collection),
|
|
146
|
+
...body
|
|
147
|
+
};
|
|
148
|
+
storage.setCollection(resource, [...collection, item]);
|
|
149
|
+
storage.persist();
|
|
150
|
+
return item;
|
|
151
|
+
}
|
|
152
|
+
function replaceItem(storage, resource, id, body) {
|
|
153
|
+
const collection = storage.getCollection(resource) ?? [];
|
|
154
|
+
const idx = findIndexById(collection, id);
|
|
155
|
+
if (idx === -1) return void 0;
|
|
156
|
+
const updated = { ...body, id: collection[idx]["id"] };
|
|
157
|
+
collection[idx] = updated;
|
|
158
|
+
storage.setCollection(resource, collection);
|
|
159
|
+
storage.persist();
|
|
160
|
+
return updated;
|
|
161
|
+
}
|
|
162
|
+
function patchItem(storage, resource, id, body) {
|
|
163
|
+
const collection = storage.getCollection(resource) ?? [];
|
|
164
|
+
const idx = findIndexById(collection, id);
|
|
165
|
+
if (idx === -1) return void 0;
|
|
166
|
+
const updated = { ...collection[idx], ...body };
|
|
167
|
+
collection[idx] = updated;
|
|
168
|
+
storage.setCollection(resource, collection);
|
|
169
|
+
storage.persist();
|
|
170
|
+
return updated;
|
|
171
|
+
}
|
|
172
|
+
function deleteItem(storage, resource, id) {
|
|
173
|
+
const collection = storage.getCollection(resource) ?? [];
|
|
174
|
+
const idx = findIndexById(collection, id);
|
|
175
|
+
if (idx === -1) return void 0;
|
|
176
|
+
const [deleted] = collection.splice(idx, 1);
|
|
177
|
+
storage.setCollection(resource, collection);
|
|
178
|
+
storage.persist();
|
|
179
|
+
return deleted;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// src/router/routes/collection.routes.ts
|
|
183
|
+
function registerCollectionRoutes(server, storage, resource, prefix) {
|
|
184
|
+
server.get(prefix, () => storage.getCollection(resource) ?? []);
|
|
185
|
+
server.post(prefix, (req, reply) => {
|
|
186
|
+
const item = createItem(storage, resource, req.body);
|
|
187
|
+
return reply.status(201).send(item);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// src/router/routes/item.routes.ts
|
|
192
|
+
function registerItemRoutes(server, storage, resource, prefix) {
|
|
193
|
+
server.get(`${prefix}/:id`, (req, reply) => {
|
|
194
|
+
const item = findById(storage.getCollection(resource) ?? [], req.params.id);
|
|
195
|
+
if (!item) return reply.status(404).send({ error: "Not found" });
|
|
196
|
+
return item;
|
|
197
|
+
});
|
|
198
|
+
server.put(`${prefix}/:id`, (req, reply) => {
|
|
199
|
+
const item = replaceItem(storage, resource, req.params.id, req.body);
|
|
200
|
+
if (!item) return reply.status(404).send({ error: "Not found" });
|
|
201
|
+
return item;
|
|
202
|
+
});
|
|
203
|
+
server.patch(`${prefix}/:id`, (req, reply) => {
|
|
204
|
+
const item = patchItem(storage, resource, req.params.id, req.body);
|
|
205
|
+
if (!item) return reply.status(404).send({ error: "Not found" });
|
|
206
|
+
return item;
|
|
207
|
+
});
|
|
208
|
+
server.delete(`${prefix}/:id`, (req, reply) => {
|
|
209
|
+
const item = deleteItem(storage, resource, req.params.id);
|
|
210
|
+
if (!item) return reply.status(404).send({ error: "Not found" });
|
|
211
|
+
return item;
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// src/router/routes/nested.routes.ts
|
|
216
|
+
function registerNestedRoutes(server, storage, relations, base) {
|
|
217
|
+
for (const [child, fields] of Object.entries(relations)) {
|
|
218
|
+
for (const [field, parent] of Object.entries(fields)) {
|
|
219
|
+
server.get(
|
|
220
|
+
`${base}/${parent}/:id/${child}`,
|
|
221
|
+
(req, reply) => {
|
|
222
|
+
const parentCollection = storage.getCollection(parent) ?? [];
|
|
223
|
+
const parentItem = findById(parentCollection, req.params.id);
|
|
224
|
+
if (!parentItem) return reply.status(404).send({ error: "Not found" });
|
|
225
|
+
const children = (storage.getCollection(child) ?? []).filter(
|
|
226
|
+
(item) => String(item[field]) === req.params.id
|
|
227
|
+
);
|
|
228
|
+
return children;
|
|
229
|
+
}
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// src/router/resource.router.ts
|
|
236
|
+
function registerResourceRoutes(server, storage, base) {
|
|
237
|
+
for (const resource of Object.keys(storage.getData())) {
|
|
238
|
+
const prefix = `${base}/${resource}`;
|
|
239
|
+
registerCollectionRoutes(server, storage, resource, prefix);
|
|
240
|
+
registerItemRoutes(server, storage, resource, prefix);
|
|
241
|
+
}
|
|
242
|
+
registerNestedRoutes(server, storage, storage.getRelations(), base);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// src/server/createServer.ts
|
|
246
|
+
async function createServer(storage, options) {
|
|
247
|
+
const server = Fastify();
|
|
248
|
+
await server.register(cors);
|
|
249
|
+
registerResourceRoutes(server, storage, options.base);
|
|
250
|
+
return server;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// src/config/loadOptions.ts
|
|
254
|
+
import { z } from "zod";
|
|
255
|
+
var serverOptionsSchema = z.object({
|
|
256
|
+
file: z.string().min(1),
|
|
257
|
+
port: z.coerce.number().int().positive().default(3070),
|
|
258
|
+
host: z.string().default("localhost"),
|
|
259
|
+
base: z.string().default("").transform((v) => v && !v.startsWith("/") ? `/${v}` : v)
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// src/cli/commands/serve.ts
|
|
263
|
+
function registerServe(program2) {
|
|
264
|
+
program2.command("serve").description("Start the mock server using a YAML file as database").argument("[file]", "Path to the YAML database file", "db.yml").option("-p, --port <number>", "Port to listen on", "3070").option("-H, --host <host>", "Host to bind", "localhost").option("-b, --base <path>", "Base path prefix for all routes", "").action(async (file, flags) => {
|
|
265
|
+
const options = serverOptionsSchema.parse({
|
|
266
|
+
file,
|
|
267
|
+
port: flags["port"],
|
|
268
|
+
host: flags["host"],
|
|
269
|
+
base: flags["base"]
|
|
270
|
+
});
|
|
271
|
+
const storage = createYamlStorage(options.file);
|
|
272
|
+
const server = await createServer(storage, options);
|
|
273
|
+
await server.listen({ port: options.port, host: options.host });
|
|
274
|
+
const collections = Object.keys(storage.getData());
|
|
275
|
+
const baseLabel = options.base || "/";
|
|
276
|
+
console.log(`
|
|
277
|
+
yrest running at http://${options.host}:${options.port}`);
|
|
278
|
+
console.log(`
|
|
279
|
+
Resources (base: ${baseLabel}):`);
|
|
280
|
+
for (const name of collections) {
|
|
281
|
+
console.log(` /${name}`);
|
|
282
|
+
}
|
|
283
|
+
console.log("");
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// src/cli/index.ts
|
|
288
|
+
program.name("yrest").description("Zero-config REST API mock server powered by a YAML file").version("0.1.0");
|
|
289
|
+
registerInit(program);
|
|
290
|
+
registerServe(program);
|
|
291
|
+
program.parse();
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import * as fastify from 'fastify';
|
|
2
|
+
import * as http from 'http';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
type Resource = Record<string, unknown>;
|
|
6
|
+
type DbData = Record<string, Resource[]>;
|
|
7
|
+
type Relations = Record<string, Record<string, string>>;
|
|
8
|
+
|
|
9
|
+
interface YamlStorage {
|
|
10
|
+
getData(): DbData;
|
|
11
|
+
getRelations(): Relations;
|
|
12
|
+
getCollection(name: string): Resource[] | undefined;
|
|
13
|
+
setCollection(name: string, items: Resource[]): void;
|
|
14
|
+
persist(): void;
|
|
15
|
+
}
|
|
16
|
+
declare function createYamlStorage(filePath: string): YamlStorage;
|
|
17
|
+
|
|
18
|
+
declare const serverOptionsSchema: z.ZodObject<{
|
|
19
|
+
file: z.ZodString;
|
|
20
|
+
port: z.ZodDefault<z.ZodNumber>;
|
|
21
|
+
host: z.ZodDefault<z.ZodString>;
|
|
22
|
+
base: z.ZodEffects<z.ZodDefault<z.ZodString>, string, string | undefined>;
|
|
23
|
+
}, "strip", z.ZodTypeAny, {
|
|
24
|
+
file: string;
|
|
25
|
+
port: number;
|
|
26
|
+
host: string;
|
|
27
|
+
base: string;
|
|
28
|
+
}, {
|
|
29
|
+
file: string;
|
|
30
|
+
port?: number | undefined;
|
|
31
|
+
host?: string | undefined;
|
|
32
|
+
base?: string | undefined;
|
|
33
|
+
}>;
|
|
34
|
+
type ServerOptions = z.infer<typeof serverOptionsSchema>;
|
|
35
|
+
|
|
36
|
+
declare function createServer(storage: YamlStorage, options: ServerOptions): Promise<fastify.FastifyInstance<http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>, http.IncomingMessage, http.ServerResponse<http.IncomingMessage>, fastify.FastifyBaseLogger, fastify.FastifyTypeProviderDefault>>;
|
|
37
|
+
|
|
38
|
+
export { type DbData, type Relations, type Resource, type ServerOptions, createServer, createYamlStorage, serverOptionsSchema };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import * as fastify from 'fastify';
|
|
2
|
+
import * as http from 'http';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
type Resource = Record<string, unknown>;
|
|
6
|
+
type DbData = Record<string, Resource[]>;
|
|
7
|
+
type Relations = Record<string, Record<string, string>>;
|
|
8
|
+
|
|
9
|
+
interface YamlStorage {
|
|
10
|
+
getData(): DbData;
|
|
11
|
+
getRelations(): Relations;
|
|
12
|
+
getCollection(name: string): Resource[] | undefined;
|
|
13
|
+
setCollection(name: string, items: Resource[]): void;
|
|
14
|
+
persist(): void;
|
|
15
|
+
}
|
|
16
|
+
declare function createYamlStorage(filePath: string): YamlStorage;
|
|
17
|
+
|
|
18
|
+
declare const serverOptionsSchema: z.ZodObject<{
|
|
19
|
+
file: z.ZodString;
|
|
20
|
+
port: z.ZodDefault<z.ZodNumber>;
|
|
21
|
+
host: z.ZodDefault<z.ZodString>;
|
|
22
|
+
base: z.ZodEffects<z.ZodDefault<z.ZodString>, string, string | undefined>;
|
|
23
|
+
}, "strip", z.ZodTypeAny, {
|
|
24
|
+
file: string;
|
|
25
|
+
port: number;
|
|
26
|
+
host: string;
|
|
27
|
+
base: string;
|
|
28
|
+
}, {
|
|
29
|
+
file: string;
|
|
30
|
+
port?: number | undefined;
|
|
31
|
+
host?: string | undefined;
|
|
32
|
+
base?: string | undefined;
|
|
33
|
+
}>;
|
|
34
|
+
type ServerOptions = z.infer<typeof serverOptionsSchema>;
|
|
35
|
+
|
|
36
|
+
declare function createServer(storage: YamlStorage, options: ServerOptions): Promise<fastify.FastifyInstance<http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>, http.IncomingMessage, http.ServerResponse<http.IncomingMessage>, fastify.FastifyBaseLogger, fastify.FastifyTypeProviderDefault>>;
|
|
37
|
+
|
|
38
|
+
export { type DbData, type Relations, type Resource, type ServerOptions, createServer, createYamlStorage, serverOptionsSchema };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var src_exports = {};
|
|
32
|
+
__export(src_exports, {
|
|
33
|
+
createServer: () => createServer,
|
|
34
|
+
createYamlStorage: () => createYamlStorage,
|
|
35
|
+
serverOptionsSchema: () => serverOptionsSchema
|
|
36
|
+
});
|
|
37
|
+
module.exports = __toCommonJS(src_exports);
|
|
38
|
+
|
|
39
|
+
// src/storage/yamlStorage.ts
|
|
40
|
+
var import_node_fs = require("fs");
|
|
41
|
+
var import_node_path = require("path");
|
|
42
|
+
var import_node_crypto = require("crypto");
|
|
43
|
+
var import_yaml = require("yaml");
|
|
44
|
+
function createYamlStorage(filePath) {
|
|
45
|
+
const absPath = (0, import_node_path.resolve)(filePath);
|
|
46
|
+
const raw = (0, import_yaml.parse)((0, import_node_fs.readFileSync)(absPath, "utf8")) ?? {};
|
|
47
|
+
const relations = raw["_rel"] ?? {};
|
|
48
|
+
const data = Object.fromEntries(
|
|
49
|
+
Object.entries(raw).filter(([key]) => key !== "_rel")
|
|
50
|
+
);
|
|
51
|
+
return {
|
|
52
|
+
getData() {
|
|
53
|
+
return data;
|
|
54
|
+
},
|
|
55
|
+
getRelations() {
|
|
56
|
+
return relations;
|
|
57
|
+
},
|
|
58
|
+
getCollection(name) {
|
|
59
|
+
return data[name];
|
|
60
|
+
},
|
|
61
|
+
setCollection(name, items) {
|
|
62
|
+
data[name] = items;
|
|
63
|
+
},
|
|
64
|
+
persist() {
|
|
65
|
+
const payload = Object.keys(relations).length > 0 ? { _rel: relations, ...data } : { ...data };
|
|
66
|
+
const tmp = (0, import_node_path.resolve)((0, import_node_path.dirname)(absPath), `.yrest-${(0, import_node_crypto.randomUUID)()}.tmp`);
|
|
67
|
+
(0, import_node_fs.writeFileSync)(tmp, (0, import_yaml.stringify)(payload), "utf8");
|
|
68
|
+
(0, import_node_fs.renameSync)(tmp, absPath);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// src/server/createServer.ts
|
|
74
|
+
var import_fastify = __toESM(require("fastify"));
|
|
75
|
+
var import_cors = __toESM(require("@fastify/cors"));
|
|
76
|
+
|
|
77
|
+
// src/services/resourceService.ts
|
|
78
|
+
function nextId(items) {
|
|
79
|
+
const ids = items.map((i) => i["id"]).filter((id) => typeof id === "number");
|
|
80
|
+
return ids.length > 0 ? Math.max(...ids) + 1 : 1;
|
|
81
|
+
}
|
|
82
|
+
function findById(items, id) {
|
|
83
|
+
return items.find((i) => String(i["id"]) === id);
|
|
84
|
+
}
|
|
85
|
+
function findIndexById(items, id) {
|
|
86
|
+
return items.findIndex((i) => String(i["id"]) === id);
|
|
87
|
+
}
|
|
88
|
+
function createItem(storage, resource, body) {
|
|
89
|
+
const collection = storage.getCollection(resource) ?? [];
|
|
90
|
+
const item = {
|
|
91
|
+
id: body["id"] !== void 0 ? body["id"] : nextId(collection),
|
|
92
|
+
...body
|
|
93
|
+
};
|
|
94
|
+
storage.setCollection(resource, [...collection, item]);
|
|
95
|
+
storage.persist();
|
|
96
|
+
return item;
|
|
97
|
+
}
|
|
98
|
+
function replaceItem(storage, resource, id, body) {
|
|
99
|
+
const collection = storage.getCollection(resource) ?? [];
|
|
100
|
+
const idx = findIndexById(collection, id);
|
|
101
|
+
if (idx === -1) return void 0;
|
|
102
|
+
const updated = { ...body, id: collection[idx]["id"] };
|
|
103
|
+
collection[idx] = updated;
|
|
104
|
+
storage.setCollection(resource, collection);
|
|
105
|
+
storage.persist();
|
|
106
|
+
return updated;
|
|
107
|
+
}
|
|
108
|
+
function patchItem(storage, resource, id, body) {
|
|
109
|
+
const collection = storage.getCollection(resource) ?? [];
|
|
110
|
+
const idx = findIndexById(collection, id);
|
|
111
|
+
if (idx === -1) return void 0;
|
|
112
|
+
const updated = { ...collection[idx], ...body };
|
|
113
|
+
collection[idx] = updated;
|
|
114
|
+
storage.setCollection(resource, collection);
|
|
115
|
+
storage.persist();
|
|
116
|
+
return updated;
|
|
117
|
+
}
|
|
118
|
+
function deleteItem(storage, resource, id) {
|
|
119
|
+
const collection = storage.getCollection(resource) ?? [];
|
|
120
|
+
const idx = findIndexById(collection, id);
|
|
121
|
+
if (idx === -1) return void 0;
|
|
122
|
+
const [deleted] = collection.splice(idx, 1);
|
|
123
|
+
storage.setCollection(resource, collection);
|
|
124
|
+
storage.persist();
|
|
125
|
+
return deleted;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// src/router/routes/collection.routes.ts
|
|
129
|
+
function registerCollectionRoutes(server, storage, resource, prefix) {
|
|
130
|
+
server.get(prefix, () => storage.getCollection(resource) ?? []);
|
|
131
|
+
server.post(prefix, (req, reply) => {
|
|
132
|
+
const item = createItem(storage, resource, req.body);
|
|
133
|
+
return reply.status(201).send(item);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// src/router/routes/item.routes.ts
|
|
138
|
+
function registerItemRoutes(server, storage, resource, prefix) {
|
|
139
|
+
server.get(`${prefix}/:id`, (req, reply) => {
|
|
140
|
+
const item = findById(storage.getCollection(resource) ?? [], req.params.id);
|
|
141
|
+
if (!item) return reply.status(404).send({ error: "Not found" });
|
|
142
|
+
return item;
|
|
143
|
+
});
|
|
144
|
+
server.put(`${prefix}/:id`, (req, reply) => {
|
|
145
|
+
const item = replaceItem(storage, resource, req.params.id, req.body);
|
|
146
|
+
if (!item) return reply.status(404).send({ error: "Not found" });
|
|
147
|
+
return item;
|
|
148
|
+
});
|
|
149
|
+
server.patch(`${prefix}/:id`, (req, reply) => {
|
|
150
|
+
const item = patchItem(storage, resource, req.params.id, req.body);
|
|
151
|
+
if (!item) return reply.status(404).send({ error: "Not found" });
|
|
152
|
+
return item;
|
|
153
|
+
});
|
|
154
|
+
server.delete(`${prefix}/:id`, (req, reply) => {
|
|
155
|
+
const item = deleteItem(storage, resource, req.params.id);
|
|
156
|
+
if (!item) return reply.status(404).send({ error: "Not found" });
|
|
157
|
+
return item;
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// src/router/routes/nested.routes.ts
|
|
162
|
+
function registerNestedRoutes(server, storage, relations, base) {
|
|
163
|
+
for (const [child, fields] of Object.entries(relations)) {
|
|
164
|
+
for (const [field, parent] of Object.entries(fields)) {
|
|
165
|
+
server.get(
|
|
166
|
+
`${base}/${parent}/:id/${child}`,
|
|
167
|
+
(req, reply) => {
|
|
168
|
+
const parentCollection = storage.getCollection(parent) ?? [];
|
|
169
|
+
const parentItem = findById(parentCollection, req.params.id);
|
|
170
|
+
if (!parentItem) return reply.status(404).send({ error: "Not found" });
|
|
171
|
+
const children = (storage.getCollection(child) ?? []).filter(
|
|
172
|
+
(item) => String(item[field]) === req.params.id
|
|
173
|
+
);
|
|
174
|
+
return children;
|
|
175
|
+
}
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// src/router/resource.router.ts
|
|
182
|
+
function registerResourceRoutes(server, storage, base) {
|
|
183
|
+
for (const resource of Object.keys(storage.getData())) {
|
|
184
|
+
const prefix = `${base}/${resource}`;
|
|
185
|
+
registerCollectionRoutes(server, storage, resource, prefix);
|
|
186
|
+
registerItemRoutes(server, storage, resource, prefix);
|
|
187
|
+
}
|
|
188
|
+
registerNestedRoutes(server, storage, storage.getRelations(), base);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// src/server/createServer.ts
|
|
192
|
+
async function createServer(storage, options) {
|
|
193
|
+
const server = (0, import_fastify.default)();
|
|
194
|
+
await server.register(import_cors.default);
|
|
195
|
+
registerResourceRoutes(server, storage, options.base);
|
|
196
|
+
return server;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// src/config/loadOptions.ts
|
|
200
|
+
var import_zod = require("zod");
|
|
201
|
+
var serverOptionsSchema = import_zod.z.object({
|
|
202
|
+
file: import_zod.z.string().min(1),
|
|
203
|
+
port: import_zod.z.coerce.number().int().positive().default(3070),
|
|
204
|
+
host: import_zod.z.string().default("localhost"),
|
|
205
|
+
base: import_zod.z.string().default("").transform((v) => v && !v.startsWith("/") ? `/${v}` : v)
|
|
206
|
+
});
|
|
207
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
208
|
+
0 && (module.exports = {
|
|
209
|
+
createServer,
|
|
210
|
+
createYamlStorage,
|
|
211
|
+
serverOptionsSchema
|
|
212
|
+
});
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// src/storage/yamlStorage.ts
|
|
2
|
+
import { readFileSync, writeFileSync, renameSync } from "fs";
|
|
3
|
+
import { resolve, dirname } from "path";
|
|
4
|
+
import { randomUUID } from "crypto";
|
|
5
|
+
import { parse, stringify } from "yaml";
|
|
6
|
+
function createYamlStorage(filePath) {
|
|
7
|
+
const absPath = resolve(filePath);
|
|
8
|
+
const raw = parse(readFileSync(absPath, "utf8")) ?? {};
|
|
9
|
+
const relations = raw["_rel"] ?? {};
|
|
10
|
+
const data = Object.fromEntries(
|
|
11
|
+
Object.entries(raw).filter(([key]) => key !== "_rel")
|
|
12
|
+
);
|
|
13
|
+
return {
|
|
14
|
+
getData() {
|
|
15
|
+
return data;
|
|
16
|
+
},
|
|
17
|
+
getRelations() {
|
|
18
|
+
return relations;
|
|
19
|
+
},
|
|
20
|
+
getCollection(name) {
|
|
21
|
+
return data[name];
|
|
22
|
+
},
|
|
23
|
+
setCollection(name, items) {
|
|
24
|
+
data[name] = items;
|
|
25
|
+
},
|
|
26
|
+
persist() {
|
|
27
|
+
const payload = Object.keys(relations).length > 0 ? { _rel: relations, ...data } : { ...data };
|
|
28
|
+
const tmp = resolve(dirname(absPath), `.yrest-${randomUUID()}.tmp`);
|
|
29
|
+
writeFileSync(tmp, stringify(payload), "utf8");
|
|
30
|
+
renameSync(tmp, absPath);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// src/server/createServer.ts
|
|
36
|
+
import Fastify from "fastify";
|
|
37
|
+
import cors from "@fastify/cors";
|
|
38
|
+
|
|
39
|
+
// src/services/resourceService.ts
|
|
40
|
+
function nextId(items) {
|
|
41
|
+
const ids = items.map((i) => i["id"]).filter((id) => typeof id === "number");
|
|
42
|
+
return ids.length > 0 ? Math.max(...ids) + 1 : 1;
|
|
43
|
+
}
|
|
44
|
+
function findById(items, id) {
|
|
45
|
+
return items.find((i) => String(i["id"]) === id);
|
|
46
|
+
}
|
|
47
|
+
function findIndexById(items, id) {
|
|
48
|
+
return items.findIndex((i) => String(i["id"]) === id);
|
|
49
|
+
}
|
|
50
|
+
function createItem(storage, resource, body) {
|
|
51
|
+
const collection = storage.getCollection(resource) ?? [];
|
|
52
|
+
const item = {
|
|
53
|
+
id: body["id"] !== void 0 ? body["id"] : nextId(collection),
|
|
54
|
+
...body
|
|
55
|
+
};
|
|
56
|
+
storage.setCollection(resource, [...collection, item]);
|
|
57
|
+
storage.persist();
|
|
58
|
+
return item;
|
|
59
|
+
}
|
|
60
|
+
function replaceItem(storage, resource, id, body) {
|
|
61
|
+
const collection = storage.getCollection(resource) ?? [];
|
|
62
|
+
const idx = findIndexById(collection, id);
|
|
63
|
+
if (idx === -1) return void 0;
|
|
64
|
+
const updated = { ...body, id: collection[idx]["id"] };
|
|
65
|
+
collection[idx] = updated;
|
|
66
|
+
storage.setCollection(resource, collection);
|
|
67
|
+
storage.persist();
|
|
68
|
+
return updated;
|
|
69
|
+
}
|
|
70
|
+
function patchItem(storage, resource, id, body) {
|
|
71
|
+
const collection = storage.getCollection(resource) ?? [];
|
|
72
|
+
const idx = findIndexById(collection, id);
|
|
73
|
+
if (idx === -1) return void 0;
|
|
74
|
+
const updated = { ...collection[idx], ...body };
|
|
75
|
+
collection[idx] = updated;
|
|
76
|
+
storage.setCollection(resource, collection);
|
|
77
|
+
storage.persist();
|
|
78
|
+
return updated;
|
|
79
|
+
}
|
|
80
|
+
function deleteItem(storage, resource, id) {
|
|
81
|
+
const collection = storage.getCollection(resource) ?? [];
|
|
82
|
+
const idx = findIndexById(collection, id);
|
|
83
|
+
if (idx === -1) return void 0;
|
|
84
|
+
const [deleted] = collection.splice(idx, 1);
|
|
85
|
+
storage.setCollection(resource, collection);
|
|
86
|
+
storage.persist();
|
|
87
|
+
return deleted;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// src/router/routes/collection.routes.ts
|
|
91
|
+
function registerCollectionRoutes(server, storage, resource, prefix) {
|
|
92
|
+
server.get(prefix, () => storage.getCollection(resource) ?? []);
|
|
93
|
+
server.post(prefix, (req, reply) => {
|
|
94
|
+
const item = createItem(storage, resource, req.body);
|
|
95
|
+
return reply.status(201).send(item);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// src/router/routes/item.routes.ts
|
|
100
|
+
function registerItemRoutes(server, storage, resource, prefix) {
|
|
101
|
+
server.get(`${prefix}/:id`, (req, reply) => {
|
|
102
|
+
const item = findById(storage.getCollection(resource) ?? [], req.params.id);
|
|
103
|
+
if (!item) return reply.status(404).send({ error: "Not found" });
|
|
104
|
+
return item;
|
|
105
|
+
});
|
|
106
|
+
server.put(`${prefix}/:id`, (req, reply) => {
|
|
107
|
+
const item = replaceItem(storage, resource, req.params.id, req.body);
|
|
108
|
+
if (!item) return reply.status(404).send({ error: "Not found" });
|
|
109
|
+
return item;
|
|
110
|
+
});
|
|
111
|
+
server.patch(`${prefix}/:id`, (req, reply) => {
|
|
112
|
+
const item = patchItem(storage, resource, req.params.id, req.body);
|
|
113
|
+
if (!item) return reply.status(404).send({ error: "Not found" });
|
|
114
|
+
return item;
|
|
115
|
+
});
|
|
116
|
+
server.delete(`${prefix}/:id`, (req, reply) => {
|
|
117
|
+
const item = deleteItem(storage, resource, req.params.id);
|
|
118
|
+
if (!item) return reply.status(404).send({ error: "Not found" });
|
|
119
|
+
return item;
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// src/router/routes/nested.routes.ts
|
|
124
|
+
function registerNestedRoutes(server, storage, relations, base) {
|
|
125
|
+
for (const [child, fields] of Object.entries(relations)) {
|
|
126
|
+
for (const [field, parent] of Object.entries(fields)) {
|
|
127
|
+
server.get(
|
|
128
|
+
`${base}/${parent}/:id/${child}`,
|
|
129
|
+
(req, reply) => {
|
|
130
|
+
const parentCollection = storage.getCollection(parent) ?? [];
|
|
131
|
+
const parentItem = findById(parentCollection, req.params.id);
|
|
132
|
+
if (!parentItem) return reply.status(404).send({ error: "Not found" });
|
|
133
|
+
const children = (storage.getCollection(child) ?? []).filter(
|
|
134
|
+
(item) => String(item[field]) === req.params.id
|
|
135
|
+
);
|
|
136
|
+
return children;
|
|
137
|
+
}
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// src/router/resource.router.ts
|
|
144
|
+
function registerResourceRoutes(server, storage, base) {
|
|
145
|
+
for (const resource of Object.keys(storage.getData())) {
|
|
146
|
+
const prefix = `${base}/${resource}`;
|
|
147
|
+
registerCollectionRoutes(server, storage, resource, prefix);
|
|
148
|
+
registerItemRoutes(server, storage, resource, prefix);
|
|
149
|
+
}
|
|
150
|
+
registerNestedRoutes(server, storage, storage.getRelations(), base);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// src/server/createServer.ts
|
|
154
|
+
async function createServer(storage, options) {
|
|
155
|
+
const server = Fastify();
|
|
156
|
+
await server.register(cors);
|
|
157
|
+
registerResourceRoutes(server, storage, options.base);
|
|
158
|
+
return server;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// src/config/loadOptions.ts
|
|
162
|
+
import { z } from "zod";
|
|
163
|
+
var serverOptionsSchema = z.object({
|
|
164
|
+
file: z.string().min(1),
|
|
165
|
+
port: z.coerce.number().int().positive().default(3070),
|
|
166
|
+
host: z.string().default("localhost"),
|
|
167
|
+
base: z.string().default("").transform((v) => v && !v.startsWith("/") ? `/${v}` : v)
|
|
168
|
+
});
|
|
169
|
+
export {
|
|
170
|
+
createServer,
|
|
171
|
+
createYamlStorage,
|
|
172
|
+
serverOptionsSchema
|
|
173
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aggiovato/yrest",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Zero-config REST API mock server powered by a YAML file",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"yaml",
|
|
7
|
+
"rest",
|
|
8
|
+
"mock",
|
|
9
|
+
"api",
|
|
10
|
+
"cli",
|
|
11
|
+
"fastify"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"author": "",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": ""
|
|
18
|
+
},
|
|
19
|
+
"main": "./dist/index.js",
|
|
20
|
+
"module": "./dist/index.mjs",
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"import": "./dist/index.mjs",
|
|
26
|
+
"require": "./dist/index.js"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"bin": {
|
|
30
|
+
"yrest": "./dist/cli/index.js"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsup",
|
|
37
|
+
"dev": "tsup --watch",
|
|
38
|
+
"test": "vitest",
|
|
39
|
+
"test:run": "vitest run",
|
|
40
|
+
"typecheck": "tsc --noEmit",
|
|
41
|
+
"prepublishOnly": "npm run build"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@fastify/cors": "^10.0.0",
|
|
45
|
+
"commander": "^12.1.0",
|
|
46
|
+
"fastify": "^5.0.0",
|
|
47
|
+
"yaml": "^2.4.5",
|
|
48
|
+
"zod": "^3.23.8"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@types/node": "^22.0.0",
|
|
52
|
+
"tsup": "^8.1.0",
|
|
53
|
+
"typescript": "^5.5.0",
|
|
54
|
+
"vitest": "^4.1.8"
|
|
55
|
+
},
|
|
56
|
+
"engines": {
|
|
57
|
+
"node": ">=20"
|
|
58
|
+
}
|
|
59
|
+
}
|