@automate-crud/mongoose 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 +56 -0
- package/package.json +19 -0
- package/src/createCrudRouter.js +189 -0
- package/src/crudErrorHandler.js +8 -0
- package/src/index.js +2 -0
- package/test/run-tests.js +56 -0
- package/test/smoke.js +33 -0
package/README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# @automate-crud/mongoose
|
|
2
|
+
|
|
3
|
+
Auto-generate CRUD APIs for Express using a Mongoose model.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm i @automate-crud/mongoose express mongoose
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```js
|
|
14
|
+
import express from "express";
|
|
15
|
+
import mongoose from "mongoose";
|
|
16
|
+
import { createCrudRouter, crudErrorHandler } from "@automate-crud/mongoose";
|
|
17
|
+
|
|
18
|
+
await mongoose.connect(process.env.MONGO_URI);
|
|
19
|
+
|
|
20
|
+
const User = mongoose.model(
|
|
21
|
+
"User",
|
|
22
|
+
new mongoose.Schema({ name: String, email: String }, { timestamps: true })
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
const app = express();
|
|
26
|
+
app.use(express.json());
|
|
27
|
+
|
|
28
|
+
app.use(
|
|
29
|
+
"/users",
|
|
30
|
+
createCrudRouter({
|
|
31
|
+
model: User,
|
|
32
|
+
searchFields: ["name", "email"],
|
|
33
|
+
allowedIncludes: []
|
|
34
|
+
})
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
app.use(crudErrorHandler);
|
|
38
|
+
app.listen(4000);
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Generated Routes
|
|
42
|
+
|
|
43
|
+
- `GET /users`
|
|
44
|
+
- `GET /users/:id`
|
|
45
|
+
- `POST /users`
|
|
46
|
+
- `PATCH /users/:id`
|
|
47
|
+
- `DELETE /users/:id`
|
|
48
|
+
|
|
49
|
+
## List Query Params
|
|
50
|
+
|
|
51
|
+
- `page`, `limit`
|
|
52
|
+
- `sort`
|
|
53
|
+
- `select`
|
|
54
|
+
- `q` (search)
|
|
55
|
+
- `include`
|
|
56
|
+
- `filter` (JSON string)
|
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@automate-crud/mongoose",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Auto CRUD for Express + Mongoose",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"peerDependencies": {
|
|
9
|
+
"express": "^4.18.0 || ^5.0.0",
|
|
10
|
+
"mongoose": "^8.0.0"
|
|
11
|
+
},
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"supertest": "^7.1.0",
|
|
14
|
+
"mongodb-memory-server": "^10.1.0"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"test": "node test/run-tests.js"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
|
|
3
|
+
function parseListQuery(query = {}) {
|
|
4
|
+
const page = Math.max(1, Number(query.page) || 1);
|
|
5
|
+
const limit = Math.min(100, Math.max(1, Math.max(1, Number(query.limit) || 20)));
|
|
6
|
+
const skip = (page - 1) * limit;
|
|
7
|
+
|
|
8
|
+
return {
|
|
9
|
+
page,
|
|
10
|
+
limit,
|
|
11
|
+
skip,
|
|
12
|
+
sort: query.sort || "-createdAt",
|
|
13
|
+
select: query.select || "",
|
|
14
|
+
q: query.q || "",
|
|
15
|
+
include: query.include || ""
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function normalizeInclude(include) {
|
|
20
|
+
if (!include) return [];
|
|
21
|
+
|
|
22
|
+
return String(include).split(",").map((v) => v.trim()).filter(Boolean);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function pickAllowedIncludes(includes, allowedIncludes = []) {
|
|
26
|
+
if (!allowedIncludes.length) return includes;
|
|
27
|
+
const allowed = new Set(allowedIncludes);
|
|
28
|
+
return includes.filter((i) => allowed.has(i))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function parseFilter(query = {}) {
|
|
32
|
+
const reserved = new Set(["page", "limit", "sort", "select", "q", "include", "filter"]);
|
|
33
|
+
|
|
34
|
+
const fromQuery = {};
|
|
35
|
+
|
|
36
|
+
for (const [key, value] of Object.entries(query)) {
|
|
37
|
+
if (reserved.has(key)) continue;
|
|
38
|
+
fromQuery[key] = value;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!query.filter) return fromQuery;
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const parsed = JSON.parse(query.filter);
|
|
45
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
46
|
+
return { ...fromQuery, ...parsed };
|
|
47
|
+
}
|
|
48
|
+
} catch (error) {
|
|
49
|
+
// ignore invalid filter JSON
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return fromQuery;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function applySearch(filter, q, searchFields = []) {
|
|
56
|
+
if (!q || !searchFields.length) return filter;
|
|
57
|
+
|
|
58
|
+
const regex = new RegExp(q, "i");
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
...filter,
|
|
62
|
+
$or: searchFields.map((field) => ({ [field]: regex }))
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function createCrudRouter({
|
|
67
|
+
model,
|
|
68
|
+
searchFields = [],
|
|
69
|
+
allowedIncludes = [],
|
|
70
|
+
idParam = "id"
|
|
71
|
+
}) {
|
|
72
|
+
if (!model) throw new Error("createCrudRouter requires a Mongoose model");
|
|
73
|
+
|
|
74
|
+
const router = express.Router();
|
|
75
|
+
const idPath = `/:${idParam}`;
|
|
76
|
+
|
|
77
|
+
// LIST
|
|
78
|
+
router.get("/", async (req, res, next) => {
|
|
79
|
+
try {
|
|
80
|
+
const listQuery = parseListQuery(req.query);
|
|
81
|
+
const filter = applySearch(parseFilter(req.query), listQuery.q, searchFields);
|
|
82
|
+
|
|
83
|
+
let query = model.find(filter);
|
|
84
|
+
|
|
85
|
+
if (listQuery.select) query = query.select(listQuery.select);
|
|
86
|
+
if (listQuery.sort) query = query.sort(listQuery.sort);
|
|
87
|
+
|
|
88
|
+
const includes = pickAllowedIncludes(
|
|
89
|
+
normalizeInclude(listQuery.include),
|
|
90
|
+
allowedIncludes
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
for (const path of includes) {
|
|
94
|
+
query = query.populate(path);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const [items, total] = await Promise.all([
|
|
98
|
+
query.skip(listQuery.skip).limit(listQuery.limit).lean().exec(),
|
|
99
|
+
model.countDocuments(filter)
|
|
100
|
+
]);
|
|
101
|
+
|
|
102
|
+
res.json({
|
|
103
|
+
data: items,
|
|
104
|
+
meta: {
|
|
105
|
+
total,
|
|
106
|
+
page: listQuery.page,
|
|
107
|
+
limit: listQuery.limit,
|
|
108
|
+
pages: Math.max(1, Math.ceil(total / listQuery.limit))
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
} catch (error) {
|
|
112
|
+
next(error);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// GET ONE
|
|
117
|
+
router.get(idPath, async (req, res, next) => {
|
|
118
|
+
try {
|
|
119
|
+
let query = model.findById(req.params[idParam]);
|
|
120
|
+
|
|
121
|
+
if (req.query.select) query = query.select(String(req.query.select));
|
|
122
|
+
|
|
123
|
+
const includes = pickAllowedIncludes(
|
|
124
|
+
normalizeInclude(req.query.include),
|
|
125
|
+
allowedIncludes
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
for (const path of includes) {
|
|
129
|
+
query = query.populate(path);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const doc = await query.lean().exec();
|
|
133
|
+
|
|
134
|
+
if (!doc) {
|
|
135
|
+
return res.status(404).json({ error: { message: "Not found" } });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
res.json({ data: doc });
|
|
139
|
+
|
|
140
|
+
} catch (error) {
|
|
141
|
+
next(error);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// CREATE
|
|
146
|
+
router.post("/", async (req, res, next) => {
|
|
147
|
+
try {
|
|
148
|
+
const created = await model.create(req.body);
|
|
149
|
+
res.status(201).json({ data: created.toObject() });
|
|
150
|
+
} catch (error) {
|
|
151
|
+
next(error);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// UPDATE
|
|
156
|
+
router.patch(idPath, async (req, res, next) => {
|
|
157
|
+
try {
|
|
158
|
+
const updated = await model.findByIdAndUpdate(req.params[idParam], req.body, {
|
|
159
|
+
new: true,
|
|
160
|
+
runValidators: true
|
|
161
|
+
}).lean().exec()
|
|
162
|
+
|
|
163
|
+
if (!updated) {
|
|
164
|
+
return res.status(404).json({ error: { message: "Not found" } });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
res.json({ data: updated });
|
|
168
|
+
} catch (error) {
|
|
169
|
+
next(error);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// DELETE
|
|
174
|
+
router.delete(idPath, async (req, res, next) => {
|
|
175
|
+
try {
|
|
176
|
+
const deleted = await model.findByIdAndDelete(req.params[idParam]).lean().exec();
|
|
177
|
+
|
|
178
|
+
if (!deleted) {
|
|
179
|
+
return res.status(404).json({ error: { message: "Not found" } });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
res.json({ data: deleted });
|
|
183
|
+
} catch (error) {
|
|
184
|
+
next(error);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
return router;
|
|
189
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { createCrudRouter } from "../src/index.js";
|
|
3
|
+
|
|
4
|
+
function makeFindChain(result = []) {
|
|
5
|
+
const chain = {
|
|
6
|
+
select: () => chain,
|
|
7
|
+
sort: () => chain,
|
|
8
|
+
populate: () => chain,
|
|
9
|
+
skip: () => chain,
|
|
10
|
+
limit: () => chain,
|
|
11
|
+
lean: () => chain,
|
|
12
|
+
exec: async () => result
|
|
13
|
+
};
|
|
14
|
+
return chain;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function makeFindOneChain(result = null) {
|
|
18
|
+
const chain = {
|
|
19
|
+
select: () => chain,
|
|
20
|
+
populate: () => chain,
|
|
21
|
+
lean: () => chain,
|
|
22
|
+
exec: async () => result
|
|
23
|
+
};
|
|
24
|
+
return chain;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function makeUpdateDeleteChain(result = null) {
|
|
28
|
+
const chain = {
|
|
29
|
+
lean: () => chain,
|
|
30
|
+
exec: async () => result
|
|
31
|
+
};
|
|
32
|
+
return chain;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function testThrowsWithoutModel() {
|
|
36
|
+
assert.throws(() => createCrudRouter({}), /requires a Mongoose model/);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function testReturnsRouter() {
|
|
40
|
+
const fakeModel = {
|
|
41
|
+
find: () => makeFindChain([]),
|
|
42
|
+
findById: () => makeFindOneChain(null),
|
|
43
|
+
create: async (body) => ({ toObject: () => body }),
|
|
44
|
+
findByIdAndUpdate: () => makeUpdateDeleteChain(null),
|
|
45
|
+
findByIdAndDelete: () => makeUpdateDeleteChain(null),
|
|
46
|
+
countDocuments: async () => 0
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const router = createCrudRouter({ model: fakeModel });
|
|
50
|
+
assert.equal(typeof router, "function");
|
|
51
|
+
assert.equal(typeof router.handle, "function");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
testThrowsWithoutModel();
|
|
55
|
+
testReturnsRouter();
|
|
56
|
+
console.log("mongoose tests: ok");
|
package/test/smoke.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import mongoose from 'mongoose';
|
|
3
|
+
import { createCrudRouter } from '../src/index.js';
|
|
4
|
+
|
|
5
|
+
const app = express();
|
|
6
|
+
app.use(express.json());
|
|
7
|
+
|
|
8
|
+
await mongoose.connect(process.env.MONGO_URI);
|
|
9
|
+
|
|
10
|
+
const User = mongoose.model("User", new mongoose.Schema(
|
|
11
|
+
{
|
|
12
|
+
name: { type: String, required: true, index: true },
|
|
13
|
+
email: { type: String, required: true, unique: true, index: true }
|
|
14
|
+
},
|
|
15
|
+
{ timestamps: true }
|
|
16
|
+
));
|
|
17
|
+
|
|
18
|
+
app.use(
|
|
19
|
+
"/users",
|
|
20
|
+
createCrudRouter({
|
|
21
|
+
model: User,
|
|
22
|
+
searchFields: ["name", "email"],
|
|
23
|
+
allowedIncludes: []
|
|
24
|
+
})
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
app.use((err, req, res, next) => {
|
|
28
|
+
res.status(500).json({ error: { message: err.message || "Internal error" } });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
app.listen(4000, () => {
|
|
32
|
+
console.log("Running at http://localhost:4000");
|
|
33
|
+
});
|