@aaroncadillac/crudify-mongo 1.8.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +112 -0
- package/README.md +256 -202
- package/crudify.js +310 -182
- package/package.json +47 -8
- package/sanitizer.js +264 -0
- package/.github/workflows/publish.yml +0 -34
- package/.github/workflows/test.yml +0 -21
- package/example/db-connection.js +0 -15
- package/example/models/posts.js +0 -30
- package/example/models/users.js +0 -37
- package/example/routes.js +0 -15
- package/example/server.js +0 -20
- package/jest-mongodb-config.js +0 -11
- package/jest.config.js +0 -7
- package/test/crudify.test.js +0 -73
package/crudify.js
CHANGED
|
@@ -1,213 +1,341 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
1
|
+
import { STATUS_CODES } from 'node:http'
|
|
2
|
+
import { CrudifyError, createSanitizer, isPlainObject, parseJson } from './sanitizer.js'
|
|
3
|
+
|
|
4
|
+
export * from './sanitizer.js'
|
|
5
|
+
|
|
6
|
+
const OBJECT_ID_RE = /^[0-9a-fA-F]{24}$/
|
|
7
|
+
const VERBS = ['list', 'get', 'create', 'update', 'delete']
|
|
8
|
+
const ROUTES = { list: ['GET', ''], get: ['GET', '/:id'], create: ['POST', ''], update: ['PUT', '/:id'], delete: ['DELETE', '/:id'] }
|
|
9
|
+
const DEFAULT_ACTION = { GET: 'get', DELETE: 'delete' } // any other method defaults to 'update'
|
|
10
|
+
const ALWAYS_READONLY = ['_id', '__v', 'createdAt', 'updatedAt']
|
|
11
|
+
const EMPTY = Object.freeze({})
|
|
12
|
+
const forbidden = () => new CrudifyError(403, 'Forbidden')
|
|
13
|
+
const notFound = (id) => new CrudifyError(404, `ID: ${id} not found`)
|
|
14
|
+
|
|
15
|
+
// ── Errors ───────────────────────────────────────────────────────────────
|
|
16
|
+
// Clients never see Mongo/hook internals: 4xx messages come from CrudifyError,
|
|
17
|
+
// Fastify validation or the field names of a ValidationError; the rest is 500.
|
|
18
|
+
const errorBody = (status, message, code, details) =>
|
|
19
|
+
({ status, error: STATUS_CODES[status], message, ...(code && { code }), ...(details && { details }) })
|
|
20
|
+
|
|
21
|
+
const isDuplicateKeyError = (err) => !!err && (err.code === 11000 || err.cause?.code === 11000 ||
|
|
22
|
+
(Array.isArray(err.writeErrors) && err.writeErrors.some(w => w?.code === 11000 || w?.err?.code === 11000)))
|
|
23
|
+
|
|
24
|
+
const duplicateKeyFields = (err) => {
|
|
25
|
+
const src = err?.cause?.code === 11000 ? err.cause : err
|
|
26
|
+
const pattern = src?.keyPattern ?? src?.keyValue
|
|
27
|
+
if (pattern && typeof pattern === 'object') return Object.keys(pattern)
|
|
28
|
+
const index = /index:\s*(\S+)/.exec(String(src?.message))?.[1]
|
|
29
|
+
return index ? [index.replace(/_-?1$/, '')] : []
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Pure: error -> { status, body, level?, meta? } (`level` is the log level to use).
|
|
33
|
+
const toErrorResponse = (err) => {
|
|
34
|
+
const status = err?.statusCode
|
|
35
|
+
if (err instanceof CrudifyError || (status >= 400 && status < 500)) {
|
|
36
|
+
return { status, body: errorBody(status, err.message, typeof err.code === 'string' ? err.code : undefined, err.details) }
|
|
37
|
+
}
|
|
38
|
+
if (isDuplicateKeyError(err)) {
|
|
39
|
+
return { status: 409, body: errorBody(409, 'Duplicate key', 'duplicate_key'), level: 'warn', meta: { fields: duplicateKeyFields(err) } }
|
|
40
|
+
}
|
|
41
|
+
switch (err?.name) {
|
|
42
|
+
case 'ValidationError': {
|
|
43
|
+
const fields = Object.keys(err.errors ?? {})
|
|
44
|
+
const required = fields.filter(f => err.errors[f].kind === 'required')
|
|
45
|
+
return required.length
|
|
46
|
+
? { status: 400, body: errorBody(400, `Fields: [${required.join(', ')}] are required and cannot be empty`, 'validation_error') }
|
|
47
|
+
: { status: 400, body: errorBody(400, `Invalid fields: [${fields.join(', ')}]`, 'validation_error', { fields }) }
|
|
10
48
|
}
|
|
49
|
+
case 'CastError':
|
|
50
|
+
case 'StrictModeError':
|
|
51
|
+
return { status: 400, body: errorBody(400, `Invalid value for "${err.path ?? 'field'}"`, 'validation_error') }
|
|
52
|
+
case 'DocumentNotFoundError': return { status: 404, body: errorBody(404, 'Not found') }
|
|
53
|
+
case 'VersionError': return { status: 409, body: errorBody(409, 'Version conflict', 'version_conflict') }
|
|
11
54
|
}
|
|
55
|
+
return { status: 500, body: errorBody(500, 'Internal error'), level: 'error' }
|
|
12
56
|
}
|
|
13
57
|
|
|
58
|
+
// ── Scope: the server-side filter ANDed with every read and write ────────────
|
|
59
|
+
const composeFilter = (base, scope) => {
|
|
60
|
+
for (const key in scope) if (Object.hasOwn(base, key)) return { $and: [base, scope] }
|
|
61
|
+
return Object.assign(base, scope) // `base` is always a fresh object, `scope` is never mutated
|
|
62
|
+
}
|
|
14
63
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
type: schema.type,
|
|
26
|
-
properties,
|
|
27
|
-
required: schema.required
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
delete newOpts.schema.body.properties._id
|
|
32
|
-
delete newOpts.schema.body.properties.createdAt
|
|
33
|
-
delete newOpts.schema.body.properties.updatedAt
|
|
34
|
-
return newOpts
|
|
35
|
-
} else {
|
|
36
|
-
return opts
|
|
64
|
+
// Root-level scope keys can't be rewritten by the client: equality values are
|
|
65
|
+
// forced into new documents, `{ $in: [...] }` values must contain the client's.
|
|
66
|
+
const writeRules = (scope) => {
|
|
67
|
+
const rules = { keys: [], forced: {}, inLists: {} }
|
|
68
|
+
for (const key in scope) {
|
|
69
|
+
if (key[0] === '$') continue
|
|
70
|
+
const v = scope[key]
|
|
71
|
+
rules.keys.push(key)
|
|
72
|
+
if (v === null || typeof v !== 'object' || v instanceof Date || v._bsontype) rules.forced[key] = v
|
|
73
|
+
else if (Array.isArray(v.$in)) rules.inLists[key] = v.$in
|
|
37
74
|
}
|
|
75
|
+
return rules
|
|
38
76
|
}
|
|
39
77
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
78
|
+
// ── Route schemas (only when `JSONSchema` is given), built once ──────────────
|
|
79
|
+
const PAGE_PROPS = {
|
|
80
|
+
totalDocs: { type: 'integer' }, limit: { type: 'integer' }, totalPages: { type: 'integer' }, page: { type: 'integer' },
|
|
81
|
+
pagingCounter: { type: 'integer' }, hasPrevPage: { type: 'boolean' }, hasNextPage: { type: 'boolean' },
|
|
82
|
+
prevPage: { type: ['integer', 'null'] }, nextPage: { type: ['integer', 'null'] }
|
|
83
|
+
}
|
|
84
|
+
const stripRef = (n) => Array.isArray(n) ? n.map(stripRef) : n && typeof n === 'object'
|
|
85
|
+
? Object.fromEntries(Object.entries(n).filter(([k]) => k !== 'x-ref').map(([k, v]) => [k, stripRef(v)])) : n
|
|
86
|
+
const omitRequired = ({ required, ...rest }) => rest
|
|
87
|
+
|
|
88
|
+
const buildSchemas = ({ JSONSchema, readonly, schema: user = {}, bulk }) => {
|
|
89
|
+
const schemas = { list: { querystring: { type: 'object', properties: { filters: { type: 'string' }, options: { type: 'string' } } } }, get: {}, create: {}, update: {}, delete: {} }
|
|
90
|
+
if (JSONSchema) {
|
|
91
|
+
const doc = stripRef(JSONSchema)
|
|
92
|
+
// `required` is dropped on responses: `select`/`serialize` may hide fields
|
|
93
|
+
const out = omitRequired(doc)
|
|
94
|
+
const properties = Object.fromEntries(Object.entries(doc.properties ?? {}).filter(([k]) => !readonly.has(k)))
|
|
95
|
+
const required = doc.required?.filter(k => !readonly.has(k))
|
|
96
|
+
const body = { type: doc.type ?? 'object', properties, ...(required?.length && { required }) }
|
|
97
|
+
schemas.list.response = { 200: { type: 'object', properties: { docs: { type: 'array', items: out }, ...PAGE_PROPS } } }
|
|
98
|
+
schemas.get.response = schemas.delete.response = { 200: out }
|
|
99
|
+
schemas.update = { body: omitRequired(body), response: { 200: out } }
|
|
100
|
+
schemas.create = {
|
|
101
|
+
body: bulk ? { anyOf: [body, { type: 'array', minItems: 1, items: body }] } : body,
|
|
102
|
+
response: { 201: bulk ? { ...out, properties: { ...out.properties, message: { type: 'string' } } } : out }
|
|
50
103
|
}
|
|
51
|
-
return newOpts
|
|
52
|
-
} else {
|
|
53
|
-
return opts
|
|
54
104
|
}
|
|
105
|
+
for (const verb of VERBS) {
|
|
106
|
+
const u = user[verb]
|
|
107
|
+
if (u) schemas[verb] = { ...schemas[verb], ...u, ...((schemas[verb].response || u.response) && { response: { ...schemas[verb].response, ...u.response } }) }
|
|
108
|
+
}
|
|
109
|
+
return schemas
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Options: validate and precompute everything once, at registration ────────
|
|
113
|
+
const timestampNames = (schema) => {
|
|
114
|
+
const t = schema.options.timestamps
|
|
115
|
+
const name = (v, d) => typeof v === 'string' ? v : v === false ? null : d
|
|
116
|
+
return t ? [name(t.createdAt, 'createdAt'), name(t.updatedAt, 'updatedAt')].filter(Boolean) : []
|
|
55
117
|
}
|
|
56
118
|
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
if (
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
119
|
+
const normalizeOptions = (opts, log) => {
|
|
120
|
+
const { url, Model, strict = false, scope, authorize, serialize, mapError } = opts
|
|
121
|
+
if (typeof url !== 'string') throw new TypeError('Crudify: `url` is required')
|
|
122
|
+
if (typeof Model?.findOne !== 'function') throw new TypeError('Crudify: `Model` must be a Mongoose model')
|
|
123
|
+
const methods = opts.methods ?? VERBS
|
|
124
|
+
const unknown = methods.find(m => !VERBS.includes(m))
|
|
125
|
+
if (unknown) throw new TypeError(`Crudify: unknown method "${unknown}"`)
|
|
126
|
+
if (methods.includes('list') && typeof Model.paginate !== 'function') throw new TypeError('Crudify: `list` needs mongoose-paginate-v2 (Model.paginate)')
|
|
127
|
+
const hooks = opts.hooks ?? {}
|
|
128
|
+
for (const [name, fn] of Object.entries({ scope, authorize, serialize, mapError, ...hooks })) {
|
|
129
|
+
if (fn !== undefined && fn !== false && typeof fn !== 'function') throw new TypeError(`Crudify: \`${name}\` must be a function`)
|
|
130
|
+
}
|
|
131
|
+
if (strict && (scope === undefined || opts.sanitizer === false)) throw new TypeError('Crudify (strict): `scope` is required and `sanitizer` cannot be disabled')
|
|
132
|
+
if (scope === undefined) log.warn(`Crudify ${url}: no \`scope\`, every request sees every document (use \`scope: false\` to silence)`)
|
|
133
|
+
if (opts.sanitizer === false) log.warn(`Crudify ${url}: sanitizer disabled, client filters, options and bodies reach Mongo as-is`)
|
|
134
|
+
|
|
135
|
+
const hidden = new Set(opts.fields?.hidden)
|
|
136
|
+
Model.schema.eachPath((path, type) => { if (type.options?.select === false) hidden.add(path) })
|
|
137
|
+
const readonly = [...ALWAYS_READONLY, ...timestampNames(Model.schema), ...(opts.fields?.readonly ?? [])]
|
|
138
|
+
const bulk = opts.bulk === false ? false : { max: 100, insertMany: false, ...opts.bulk }
|
|
139
|
+
const compiled = createSanitizer({ ...opts.query, writable: opts.fields?.writable, readonly, hidden: [...hidden] })
|
|
140
|
+
const san = opts.sanitizer === false
|
|
141
|
+
? { // 1.x behaviour: nothing is narrowed
|
|
142
|
+
filter: (f) => { for (const k in f) if (typeof f[k] === 'string' && f[k].length > 1 && f[k][0] === '/' && f[k].endsWith('/')) f[k] = new RegExp(f[k].slice(1, -1), 'i'); return f },
|
|
143
|
+
options: (o) => Object.keys(o).length ? o : { pagination: false },
|
|
144
|
+
update: (b) => b,
|
|
145
|
+
create: (i) => i
|
|
80
146
|
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
147
|
+
: { ...compiled, ...(isPlainObject(opts.sanitizer) && opts.sanitizer) }
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
url, Model, methods, scope, authorize, serialize, mapError, hooks, bulk, san,
|
|
151
|
+
lean: !!opts.lean, select: opts.select, routeOptions: opts.routeOptions ?? {}, handlers: opts.handlers ?? {},
|
|
152
|
+
routes: opts.routes, actions: opts.actions ?? {}, additionalRoutes: opts.additionalRoutes,
|
|
153
|
+
idIsObjectId: Model.schema.path('_id')?.instance === 'ObjectId',
|
|
154
|
+
schemas: buildSchemas({ JSONSchema: opts.JSONSchema, readonly: new Set(readonly), schema: opts.schema, bulk })
|
|
85
155
|
}
|
|
86
156
|
}
|
|
87
157
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
}
|
|
108
|
-
})
|
|
158
|
+
// ── Context: the toolbox shared by built-in handlers and your own ────────────
|
|
159
|
+
const createContext = (c) => {
|
|
160
|
+
const { Model, hooks, san } = c
|
|
161
|
+
const scope = async (req, action) => {
|
|
162
|
+
if (req.crudify?.action === action) return req.crudify.filter // already resolved by this request's route
|
|
163
|
+
if (!c.scope) return EMPTY
|
|
164
|
+
const s = await c.scope(req, action)
|
|
165
|
+
if (!isPlainObject(s)) throw forbidden() // fail closed
|
|
166
|
+
return s
|
|
167
|
+
}
|
|
168
|
+
const where = async (req, action, base = {}) => composeFilter(base, await scope(req, action))
|
|
169
|
+
const checkId = (id) => { if (c.idIsObjectId && !OBJECT_ID_RE.test(id)) throw notFound(id) }
|
|
170
|
+
const find = async (req, action, id, lean) => {
|
|
171
|
+
checkId(id)
|
|
172
|
+
const doc = await Model.findOne(await where(req, action, { _id: id }), c.select, lean ? { lean: true } : undefined)
|
|
173
|
+
if (!doc) throw notFound(id)
|
|
174
|
+
return doc
|
|
175
|
+
}
|
|
176
|
+
const parseQuery = (q) => ({ filter: san.filter(parseJson(q.filters, 'filters')), options: san.options(parseJson(q.options, 'options')) })
|
|
109
177
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}
|
|
122
|
-
} catch (err) {
|
|
123
|
-
return reply.type('application/json').code(500).send({ error: err.message || err })
|
|
124
|
-
}
|
|
178
|
+
return Object.freeze({
|
|
179
|
+
Model, url: c.url, scope, where, checkId, notFound, find,
|
|
180
|
+
load: (req, action = 'get', id = req.params.id) => find(req, action, id, false), // a real document: you can .save() it
|
|
181
|
+
list: async (req) => {
|
|
182
|
+
const { filter, options } = parseQuery(req.query)
|
|
183
|
+
if (c.lean) options.lean = true
|
|
184
|
+
if (c.select) options.select = c.select
|
|
185
|
+
return Model.paginate(await where(req, 'list', filter), options)
|
|
186
|
+
},
|
|
187
|
+
sanitize: { ...san, query: (req) => parseQuery(req.query) },
|
|
188
|
+
serialize: c.serialize ?? ((doc) => doc)
|
|
125
189
|
})
|
|
190
|
+
}
|
|
126
191
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}
|
|
139
|
-
try {
|
|
140
|
-
let response;
|
|
141
|
-
if (Array.isArray(req.body)) {
|
|
142
|
-
await opts.Model.insertMany(req.body)
|
|
143
|
-
response = { message: "All documents created successfully" }
|
|
144
|
-
} else {
|
|
145
|
-
response = await opts.Model.create(req.body)
|
|
192
|
+
const createHandlers = (c, ctx) => {
|
|
193
|
+
const { Model, hooks } = c
|
|
194
|
+
const { serialize } = ctx
|
|
195
|
+
|
|
196
|
+
const prepare = (item, rules) => {
|
|
197
|
+
const data = ctx.sanitize.create(item, rules.keys)
|
|
198
|
+
for (const field in rules.inLists) {
|
|
199
|
+
const v = item[field]
|
|
200
|
+
if (v === undefined) throw new CrudifyError(400, `${field} is required`, { code: 'validation_error' })
|
|
201
|
+
if (!rules.inLists[field].some(x => String(x) === String(v))) throw forbidden()
|
|
202
|
+
data[field] = v
|
|
146
203
|
}
|
|
147
|
-
return
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
}
|
|
168
|
-
} catch (err) {
|
|
169
|
-
const requiredErrors = []
|
|
170
|
-
for (const key in err.errors) {
|
|
171
|
-
if (err.errors[key].kind === 'required') {
|
|
172
|
-
requiredErrors.push(err.errors[key].path)
|
|
173
|
-
}
|
|
204
|
+
return Object.assign(data, rules.forced)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
list: async (req) => {
|
|
209
|
+
let result = await ctx.list(req)
|
|
210
|
+
if (hooks.afterList) result = (await hooks.afterList(result, req)) ?? result
|
|
211
|
+
if (c.serialize) result.docs = result.docs.map(doc => serialize(doc, req))
|
|
212
|
+
return result
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
get: async (req) => serialize(await ctx.find(req, 'get', req.params.id, c.lean), req),
|
|
216
|
+
|
|
217
|
+
create: async (req, reply) => {
|
|
218
|
+
const rules = writeRules(req.crudify.filter)
|
|
219
|
+
const isBulk = Array.isArray(req.body)
|
|
220
|
+
const data = []
|
|
221
|
+
for (const item of isBulk ? req.body : [req.body]) {
|
|
222
|
+
const d = prepare(item, rules)
|
|
223
|
+
data.push(hooks.beforeCreate ? (await hooks.beforeCreate(d, req)) ?? d : d)
|
|
174
224
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
225
|
+
let docs
|
|
226
|
+
if (isBulk) { // validate everything first: an invalid item inserts nothing
|
|
227
|
+
await Promise.all(data.map(d => Model.validate(d)))
|
|
228
|
+
docs = await (c.bulk.insertMany ? Model.insertMany(data) : Model.create(data)) // create() runs save hooks
|
|
229
|
+
} else docs = await Model.create(data[0])
|
|
230
|
+
if (hooks.afterCreate) await hooks.afterCreate(docs, req)
|
|
231
|
+
reply.code(201)
|
|
232
|
+
return isBulk ? { message: 'All documents created successfully' } : serialize(docs, req)
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
update: async (req) => {
|
|
236
|
+
const { id } = req.params
|
|
237
|
+
const { filter } = req.crudify
|
|
238
|
+
let changes = ctx.sanitize.update(req.body, writeRules(filter).keys)
|
|
239
|
+
if (hooks.beforeUpdate) changes = (await hooks.beforeUpdate(changes, req)) ?? changes
|
|
240
|
+
const doc = await Model.findOneAndUpdate(composeFilter({ _id: id }, filter), changes,
|
|
241
|
+
{ returnDocument: 'after', runValidators: true, projection: c.select, lean: c.lean })
|
|
242
|
+
if (!doc) throw notFound(id)
|
|
243
|
+
if (hooks.afterUpdate) await hooks.afterUpdate(doc, req)
|
|
244
|
+
return serialize(doc, req)
|
|
245
|
+
},
|
|
246
|
+
|
|
247
|
+
delete: async (req) => {
|
|
248
|
+
const { id } = req.params
|
|
249
|
+
const filter = composeFilter({ _id: id }, req.crudify.filter)
|
|
250
|
+
if (hooks.beforeDelete) { // needs the document: one extra query, only if you declare the hook
|
|
251
|
+
const current = await Model.findOne(filter)
|
|
252
|
+
if (!current) throw notFound(id)
|
|
253
|
+
await hooks.beforeDelete(current, req)
|
|
183
254
|
}
|
|
255
|
+
const doc = await Model.findOneAndDelete(filter, { projection: c.select, lean: c.lean })
|
|
256
|
+
if (!doc) throw notFound(id)
|
|
257
|
+
if (hooks.afterDelete) await hooks.afterDelete(doc, req)
|
|
258
|
+
return serialize(doc, req)
|
|
184
259
|
}
|
|
185
|
-
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ── The plugin ───────────────────────────────────────────────────────────────
|
|
264
|
+
let deprecationShown = false
|
|
265
|
+
|
|
266
|
+
async function Crudify (fastify, opts) {
|
|
267
|
+
const c = normalizeOptions(opts, fastify.log)
|
|
268
|
+
const ctx = createContext(c)
|
|
269
|
+
const builtin = createHandlers(c, ctx)
|
|
270
|
+
if (!fastify.hasRequestDecorator('crudify')) fastify.decorateRequest('crudify', null)
|
|
186
271
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
272
|
+
const errorHandler = c.mapError === false ? undefined : (err, req, reply) => {
|
|
273
|
+
const { status, body, level, meta } = c.mapError?.(err, req) ?? toErrorResponse(err)
|
|
274
|
+
if (level) req.log[level](level === 'error' ? { err } : meta, level === 'error' ? 'crudify: unhandled error' : 'crudify: duplicate key')
|
|
275
|
+
return reply.code(status).send(body)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// authorize -> scope -> handler, for built-in verbs and custom routes alike
|
|
279
|
+
const begin = async (req, action, check) => {
|
|
280
|
+
check?.(req)
|
|
281
|
+
if (c.authorize && (await c.authorize(req, action)) === false) throw forbidden()
|
|
282
|
+
req.crudify = { action, filter: await ctx.scope(req, action), doc: null }
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const route = (method, path, { action = DEFAULT_ACTION[method] ?? 'update', load, ...routeOpts } = {}, handler, check) =>
|
|
286
|
+
fastify.route({
|
|
287
|
+
...c.routeOptions,
|
|
288
|
+
...routeOpts,
|
|
289
|
+
method,
|
|
290
|
+
url: c.url + path,
|
|
291
|
+
...(errorHandler && { errorHandler }),
|
|
292
|
+
handler: async (req, reply) => {
|
|
293
|
+
await begin(req, action, check)
|
|
294
|
+
if (load) req.crudify.doc = await ctx.load(req, action)
|
|
295
|
+
return handler(req, reply, ctx)
|
|
198
296
|
}
|
|
199
|
-
}
|
|
200
|
-
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
const checks = {
|
|
300
|
+
get: (req) => ctx.checkId(req.params.id),
|
|
301
|
+
update: (req) => ctx.checkId(req.params.id),
|
|
302
|
+
delete: (req) => ctx.checkId(req.params.id),
|
|
303
|
+
create: ({ body }) => {
|
|
304
|
+
if (!Array.isArray(body)) {
|
|
305
|
+
if (!isPlainObject(body)) throw new CrudifyError(400, 'Invalid request body', { code: 'invalid_body' })
|
|
306
|
+
} else if (!c.bulk) throw new CrudifyError(400, 'Bulk create is disabled', { code: 'invalid_body' })
|
|
307
|
+
else if (!body.length) throw new CrudifyError(400, 'Empty bulk body', { code: 'invalid_body' })
|
|
308
|
+
else if (body.length > c.bulk.max) throw new CrudifyError(413, `Too many items (max ${c.bulk.max})`)
|
|
201
309
|
}
|
|
202
|
-
}
|
|
310
|
+
}
|
|
203
311
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
312
|
+
for (const verb of c.methods) {
|
|
313
|
+
const [method, path] = ROUTES[verb]
|
|
314
|
+
route(method, path, { action: verb, schema: c.schemas[verb] }, c.handlers[verb] ?? builtin[verb], checks[verb])
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const api = Object.fromEntries(['get', 'post', 'put', 'patch', 'delete'].map(m =>
|
|
318
|
+
[m, (path, a, b) => b ? route(m.toUpperCase(), path, a, b) : route(m.toUpperCase(), path, {}, a)]))
|
|
319
|
+
await c.routes?.(api, ctx)
|
|
320
|
+
|
|
321
|
+
for (const [name, { handler, method = 'POST', path = `/:id/${name}`, load = true, action = name, ...rest }] of Object.entries(c.actions)) {
|
|
322
|
+
route(method.toUpperCase(), path, { ...rest, action, load }, handler)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (c.additionalRoutes) { // deprecated, removed in 3.0
|
|
326
|
+
if (!deprecationShown) { deprecationShown = true; fastify.log.warn('Crudify: `additionalRoutes` is deprecated, use `routes` or `actions`') }
|
|
327
|
+
if (typeof c.additionalRoutes === 'function') c.additionalRoutes(fastify, opts)
|
|
328
|
+
else for (const { method = 'GET', url, handler, ...rest } of c.additionalRoutes) route(method.toUpperCase(), url, rest, handler)
|
|
329
|
+
}
|
|
209
330
|
}
|
|
210
331
|
|
|
332
|
+
Crudify[Symbol.for('plugin-meta')] = { name: 'crudify-mongo', fastify: '4.x || 5.x' }
|
|
333
|
+
Crudify[Symbol.for('fastify.display-name')] = 'crudify-mongo'
|
|
334
|
+
|
|
211
335
|
export {
|
|
212
|
-
Crudify
|
|
336
|
+
Crudify,
|
|
337
|
+
composeFilter,
|
|
338
|
+
duplicateKeyFields,
|
|
339
|
+
isDuplicateKeyError,
|
|
340
|
+
toErrorResponse
|
|
213
341
|
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aaroncadillac/crudify-mongo",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "
|
|
5
|
-
"
|
|
3
|
+
"version": "3.0.0",
|
|
4
|
+
"description": "A secure-by-default CRUD generator for Fastify and Mongoose, with hooks, scoping and a built-in query sanitizer",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"fastify",
|
|
7
|
+
"mongoose",
|
|
8
|
+
"mongodb",
|
|
9
|
+
"crud",
|
|
10
|
+
"rest",
|
|
11
|
+
"sanitizer",
|
|
12
|
+
"plugin"
|
|
13
|
+
],
|
|
14
|
+
"main": "./crudify.js",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": "./crudify.js",
|
|
17
|
+
"./sanitizer": "./sanitizer.js",
|
|
18
|
+
"./package.json": "./package.json"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"crudify.js",
|
|
22
|
+
"sanitizer.js",
|
|
23
|
+
"CHANGELOG.md"
|
|
24
|
+
],
|
|
6
25
|
"repository": "git@github.com:aaroncadillac/crudify-mongo.git",
|
|
7
26
|
"author": "aaroncadillac",
|
|
8
27
|
"scripts": {
|
|
9
|
-
"test": "
|
|
28
|
+
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
|
|
29
|
+
"test:unit": "node --experimental-vm-modules node_modules/jest/bin/jest.js --selectProjects unit",
|
|
30
|
+
"test:local": "MONGOMS_SYSTEM_BINARY=$(command -v mongod) node --experimental-vm-modules node_modules/jest/bin/jest.js",
|
|
31
|
+
"bench": "node bench/run.js"
|
|
10
32
|
},
|
|
11
33
|
"license": "MPL-2.0",
|
|
12
34
|
"type": "module",
|
|
@@ -14,12 +36,29 @@
|
|
|
14
36
|
"publishConfig": {
|
|
15
37
|
"access": "public"
|
|
16
38
|
},
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=20"
|
|
41
|
+
},
|
|
42
|
+
"resolutions": {
|
|
43
|
+
"fastify/light-my-request/cookie": "^1.1.1"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"fastify": "^4.0.0 || ^5.0.0",
|
|
47
|
+
"mongoose": "^8.0.0",
|
|
48
|
+
"mongoose-paginate-v2": "^1.8.0"
|
|
49
|
+
},
|
|
50
|
+
"peerDependenciesMeta": {
|
|
51
|
+
"mongoose-paginate-v2": {
|
|
52
|
+
"optional": true
|
|
53
|
+
}
|
|
54
|
+
},
|
|
17
55
|
"devDependencies": {
|
|
56
|
+
"@fastify/auth": "^5.0.0",
|
|
18
57
|
"@shelf/jest-mongodb": "^4.3.2",
|
|
19
|
-
"fastify": "^
|
|
58
|
+
"fastify": "^5.0.0",
|
|
59
|
+
"fastify4": "npm:fastify@^4.29.0",
|
|
20
60
|
"jest": "^29.7.0",
|
|
21
|
-
"mongoose": "^8.
|
|
22
|
-
"mongoose-paginate-v2": "^1.8.2"
|
|
23
|
-
"supertest": "^7.0.0"
|
|
61
|
+
"mongoose": "^8.17.0",
|
|
62
|
+
"mongoose-paginate-v2": "^1.8.2"
|
|
24
63
|
}
|
|
25
64
|
}
|