@aaroncadillac/crudify-mongo 1.8.2 → 3.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/crudify.js CHANGED
@@ -1,213 +1,344 @@
1
- function removeKeys(obj, keysToRemove) {
2
- if (typeof obj !== 'object' || obj === null) return;
3
-
4
- const keys = Object.keys(obj);
5
- for (const key of keys) {
6
- if (keysToRemove.includes(key)) {
7
- delete obj[key];
8
- } else if (typeof obj[key] === 'object' && obj[key] !== null) {
9
- removeKeys(obj[key], keysToRemove);
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
- function addPostBody(opts) {
16
- const schema = opts.JSONSchema
17
- if ( schema ) {
18
- const properties = {...schema.properties}
19
- removeKeys(properties, ['x-ref'])
20
- const newOpts = {
21
- ...opts,
22
- schema: {
23
- ...opts.schema,
24
- body: {
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
- function addGetResponseUnitary(opts) {
41
- const schema = opts.JSONSchema
42
- if ( schema ) {
43
- const newOpts = {
44
- ...opts,
45
- schema: {
46
- response: {
47
- 200: schema
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
- function addGetResponseArray(opts) {
58
- const schema = opts.JSONSchema
59
- if ( schema ) {
60
- const newOpts = {
61
- ...opts,
62
- schema: {
63
- response: {
64
- 200: {
65
- type: 'array',
66
- items: schema
67
- }
68
- },
69
- querystring: {
70
- type: 'object',
71
- properties: {
72
- filters: {
73
- type: 'string'
74
- },
75
- options: {
76
- type: 'string'
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
+ if (opts.additionalRoutes !== undefined) throw new TypeError('Crudify: `additionalRoutes` was removed, use `routes(api, ctx)` or `actions`')
128
+ const createScope = opts.createScope === undefined ? 'strict' : opts.createScope
129
+ if (createScope !== 'strict' && createScope !== 'after-hooks') throw new TypeError('Crudify: `createScope` must be "strict" or "after-hooks"')
130
+ const hooks = opts.hooks ?? {}
131
+ for (const [name, fn] of Object.entries({ scope, authorize, serialize, mapError, ...hooks })) {
132
+ if (fn !== undefined && fn !== false && typeof fn !== 'function') throw new TypeError(`Crudify: \`${name}\` must be a function`)
133
+ }
134
+ if (strict && (scope === undefined || opts.sanitizer === false)) throw new TypeError('Crudify (strict): `scope` is required and `sanitizer` cannot be disabled')
135
+ if (scope === undefined) log.warn(`Crudify ${url}: no \`scope\`, every request sees every document (use \`scope: false\` to silence)`)
136
+ if (opts.sanitizer === false) log.warn(`Crudify ${url}: sanitizer disabled, client filters, options and bodies reach Mongo as-is`)
137
+
138
+ const hidden = new Set(opts.fields?.hidden)
139
+ Model.schema.eachPath((path, type) => { if (type.options?.select === false) hidden.add(path) })
140
+ const readonly = [...ALWAYS_READONLY, ...timestampNames(Model.schema), ...(opts.fields?.readonly ?? [])]
141
+ const bulk = opts.bulk === false ? false : { max: 100, insertMany: false, ...opts.bulk }
142
+ const compiled = createSanitizer({ ...opts.query, writable: opts.fields?.writable, readonly, hidden: [...hidden] })
143
+ const san = opts.sanitizer === false
144
+ ? { // 1.x behaviour: nothing is narrowed
145
+ 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 },
146
+ options: (o) => Object.keys(o).length ? o : { pagination: false },
147
+ update: (b) => b,
148
+ create: (i) => i
80
149
  }
81
- }
82
- return newOpts
83
- } else {
84
- return opts
150
+ : { ...compiled, ...(isPlainObject(opts.sanitizer) && opts.sanitizer) }
151
+
152
+ return {
153
+ url, Model, methods, scope, authorize, serialize, mapError, hooks, bulk, san, lateScope: createScope === 'after-hooks',
154
+ lean: !!opts.lean, select: opts.select, routeOptions: opts.routeOptions ?? {}, handlers: opts.handlers ?? {},
155
+ routes: opts.routes, actions: opts.actions ?? {},
156
+ idIsObjectId: Model.schema.path('_id')?.instance === 'ObjectId',
157
+ schemas: buildSchemas({ JSONSchema: opts.JSONSchema, readonly: new Set(readonly), schema: opts.schema, bulk })
85
158
  }
86
159
  }
87
160
 
88
- function Crudify (fastify, opts, next) {
89
- const optsGet = addGetResponseUnitary(opts)
90
- const optsGetAll = addGetResponseArray(opts)
91
- const optsPost = addPostBody(optsGet)
92
-
93
- fastify.get(`${opts.url}`, optsGetAll, async (req, reply) => {
94
- try {
95
- const filterObject = JSON.parse(req.query.filters ||= '{}')
96
- for (const key in filterObject) {
97
- if (typeof filterObject[key] === 'string') {
98
- if (filterObject[key].startsWith('/') && filterObject[key].endsWith('/')) {
99
- filterObject[key] = new RegExp(filterObject[key].slice(1, -1), 'i')
100
- }
101
- }
102
- }
103
- const paginateOptions = JSON.parse(req.query.options ||= '{"pagination": false}')
104
- return reply.type('application/json').code(200).send(await opts.Model.paginate(filterObject, paginateOptions))
105
- } catch (err) {
106
- return reply.type('application/json').code(500).send({ error: err.message || err })
107
- }
108
- })
161
+ // ── Context: the toolbox shared by built-in handlers and your own ────────────
162
+ const createContext = (c) => {
163
+ const { Model, hooks, san } = c
164
+ const scope = async (req, action) => {
165
+ if (req.crudify?.action === action) return req.crudify.filter // already resolved by this request's route
166
+ if (!c.scope) return EMPTY
167
+ const s = await c.scope(req, action)
168
+ if (!isPlainObject(s)) throw forbidden() // fail closed
169
+ return s
170
+ }
171
+ const where = async (req, action, base = {}) => composeFilter(base, await scope(req, action))
172
+ const checkId = (id) => { if (c.idIsObjectId && !OBJECT_ID_RE.test(id)) throw notFound(id) }
173
+ const find = async (req, action, id, lean) => {
174
+ checkId(id)
175
+ const doc = await Model.findOne(await where(req, action, { _id: id }), c.select, lean ? { lean: true } : undefined)
176
+ if (!doc) throw notFound(id)
177
+ return doc
178
+ }
179
+ const parseQuery = (q) => ({ filter: san.filter(parseJson(q.filters, 'filters')), options: san.options(parseJson(q.options, 'options')) })
109
180
 
110
- fastify.get(`${opts.url}/:id`, optsGet, async (req, reply) => {
111
- try {
112
- const response = await opts.Model.findById(req.params.id)
113
- if (!response) {
114
- return reply.type('application/json').code(404).send({
115
- status: 404,
116
- error: 'Not Found',
117
- message: `ID: ${ req.params.id } not found`
118
- })
119
- } else {
120
- return reply.type('application/json').code(200).send(response)
121
- }
122
- } catch (err) {
123
- return reply.type('application/json').code(500).send({ error: err.message || err })
124
- }
181
+ return Object.freeze({
182
+ Model, url: c.url, scope, where, checkId, notFound, find,
183
+ load: (req, action = 'get', id = req.params.id) => find(req, action, id, false), // a real document: you can .save() it
184
+ list: async (req) => {
185
+ const { filter, options } = parseQuery(req.query)
186
+ if (c.lean) options.lean = true
187
+ if (c.select) options.select = c.select
188
+ return Model.paginate(await where(req, 'list', filter), options)
189
+ },
190
+ sanitize: { ...san, query: (req) => parseQuery(req.query) },
191
+ serialize: c.serialize ?? ((doc) => doc)
125
192
  })
193
+ }
126
194
 
127
- fastify.post(`${opts.url}`, optsPost, async (req, reply) => {
128
- try {
129
- if (Array.isArray(req.body)) {
130
- await Promise.all(req.body.map(item => opts.Model.validate(item)))
131
- } else if (typeof req.body === 'object' && req.body !== null) {
132
- await opts.Model.validate(req.body)
133
- } else {
134
- return reply.type('application/json').code(400).send({ error: "Invalid request body" })
135
- }
136
- } catch (err) {
137
- return reply.type('application/json').code(400).send({ error: err.message || err })
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)
195
+ const createHandlers = (c, ctx) => {
196
+ const { Model, hooks } = c
197
+ const { serialize } = ctx
198
+
199
+ // Scope keys of a new document: `{ $in: [...] }` values must contain the document's own, equality values are forced.
200
+ const checkLists = (data, lists) => {
201
+ for (const field in lists) {
202
+ const v = data[field]
203
+ if (v === undefined) throw new CrudifyError(400, `${field} is required`, { code: 'validation_error' })
204
+ if (!lists[field].some(x => String(x) === String(v))) throw forbidden()
146
205
  }
147
- return reply.type('application/json').code(201).send(response)
148
- } catch (err) {
149
- return reply.type('application/json').code(500).send({ error: err.message || err })
150
- }
151
- })
152
-
153
- fastify.put(`${opts.url}/:id`, optsGet, async (req, reply) => {
154
- try {
155
- const response = await opts.Model.findOneAndUpdate({ _id: req.params.id }, req.body, {
156
- new: true,
157
- runValidators: true
158
- })
159
- if (!response) {
160
- return reply.type('application/json').code(404).send({
161
- status: 404,
162
- error: 'Not Found',
163
- message: `ID: ${ req.params.id } not found`
164
- })
165
- } else {
166
- return reply.type('application/json').code(200).send(response)
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
- }
174
- }
175
- if (requiredErrors.length > 0) {
176
- return reply.type('application/json').code(400).send({
177
- status: 400,
178
- error: 'Bad Request',
179
- message: `Fields: [${ requiredErrors.join(', ') }] are required and cannot be empty`
180
- })
181
- } else {
182
- return reply.type('application/json').code(500).send({ error: err.message || err })
206
+ }
207
+
208
+ // 'strict' checks the client's document before `beforeCreate`; 'after-hooks' lets the hook derive the keys and checks
209
+ // the FINAL document instead, forcing the equality keys last so a hook can never move a document out of its scope.
210
+ const prepare = async (item, req, rules) => {
211
+ const data = ctx.sanitize.create(item, rules.keys)
212
+ for (const field in rules.inLists) if (item[field] !== undefined) data[field] = item[field]
213
+ if (!c.lateScope) { checkLists(data, rules.inLists); Object.assign(data, rules.forced) }
214
+ const out = hooks.beforeCreate ? (await hooks.beforeCreate(data, req)) ?? data : data
215
+ if (!c.lateScope) return out
216
+ checkLists(out, rules.inLists)
217
+ return Object.assign(out, rules.forced)
218
+ }
219
+
220
+ return {
221
+ list: async (req) => {
222
+ let result = await ctx.list(req)
223
+ if (hooks.afterList) result = (await hooks.afterList(result, req)) ?? result
224
+ if (c.serialize) result.docs = result.docs.map(doc => serialize(doc, req))
225
+ return result
226
+ },
227
+
228
+ get: async (req) => serialize(await ctx.find(req, 'get', req.params.id, c.lean), req),
229
+
230
+ create: async (req, reply) => {
231
+ const rules = writeRules(req.crudify.filter)
232
+ const isBulk = Array.isArray(req.body)
233
+ const data = []
234
+ for (const item of isBulk ? req.body : [req.body]) data.push(await prepare(item, req, rules))
235
+ let docs
236
+ if (isBulk) { // validate everything first: an invalid item inserts nothing
237
+ await Promise.all(data.map(d => Model.validate(d)))
238
+ docs = await (c.bulk.insertMany ? Model.insertMany(data) : Model.create(data)) // create() runs save hooks
239
+ } else docs = await Model.create(data[0])
240
+ if (hooks.afterCreate) await hooks.afterCreate(docs, req)
241
+ reply.code(201)
242
+ return isBulk ? { message: 'All documents created successfully' } : serialize(docs, req)
243
+ },
244
+
245
+ update: async (req) => {
246
+ const { id } = req.params
247
+ const { filter } = req.crudify
248
+ let changes = ctx.sanitize.update(req.body, writeRules(filter).keys)
249
+ if (hooks.beforeUpdate) changes = (await hooks.beforeUpdate(changes, req)) ?? changes
250
+ const doc = await Model.findOneAndUpdate(composeFilter({ _id: id }, filter), changes,
251
+ { returnDocument: 'after', runValidators: true, projection: c.select, lean: c.lean })
252
+ if (!doc) throw notFound(id)
253
+ if (hooks.afterUpdate) await hooks.afterUpdate(doc, req)
254
+ return serialize(doc, req)
255
+ },
256
+
257
+ delete: async (req) => {
258
+ const { id } = req.params
259
+ const filter = composeFilter({ _id: id }, req.crudify.filter)
260
+ if (hooks.beforeDelete) { // needs the document: one extra query, only if you declare the hook
261
+ const current = await Model.findOne(filter)
262
+ if (!current) throw notFound(id)
263
+ await hooks.beforeDelete(current, req)
183
264
  }
265
+ const doc = await Model.findOneAndDelete(filter, { projection: c.select, lean: c.lean })
266
+ if (!doc) throw notFound(id)
267
+ if (hooks.afterDelete) await hooks.afterDelete(doc, req)
268
+ return serialize(doc, req)
184
269
  }
185
- })
270
+ }
271
+ }
186
272
 
187
- fastify.delete(`${opts.url}/:id`, opts, async (req, reply) => {
188
- try {
189
- const response = await opts.Model.findOneAndDelete({ _id: req.params.id })
190
- if (!response) {
191
- return reply.type('application/json').code(404).send({
192
- status: 404,
193
- error: 'Not Found',
194
- message: `ID: ${ req.params.id } not found`
195
- })
196
- } else {
197
- return reply.type('application/json').code(200).send(response)
273
+ // ── The plugin ───────────────────────────────────────────────────────────────
274
+ async function Crudify (fastify, opts) {
275
+ const c = normalizeOptions(opts, fastify.log)
276
+ const ctx = createContext(c)
277
+ const builtin = createHandlers(c, ctx)
278
+ if (!fastify.hasRequestDecorator('crudify')) fastify.decorateRequest('crudify', null)
279
+
280
+ const errorHandler = c.mapError === false ? undefined : (err, req, reply) => {
281
+ const { status, body, level, meta } = c.mapError?.(err, req) ?? toErrorResponse(err)
282
+ if (level) req.log[level](level === 'error' ? { err } : meta, level === 'error' ? 'crudify: unhandled error' : 'crudify: duplicate key')
283
+ return reply.code(status).send(body)
284
+ }
285
+
286
+ // authorize -> scope -> handler, for built-in verbs and custom routes alike
287
+ const begin = async (req, action, check) => {
288
+ check?.(req)
289
+ if (c.authorize && (await c.authorize(req, action)) === false) throw forbidden()
290
+ req.crudify = { action, filter: await ctx.scope(req, action), doc: null }
291
+ }
292
+
293
+ const route = (method, path, { action = DEFAULT_ACTION[method] ?? 'update', load, ...routeOpts } = {}, handler, check) =>
294
+ fastify.route({
295
+ ...c.routeOptions,
296
+ ...routeOpts,
297
+ method,
298
+ url: c.url + path,
299
+ ...(errorHandler && { errorHandler }),
300
+ handler: async (req, reply) => {
301
+ await begin(req, action, check)
302
+ if (load) req.crudify.doc = await ctx.load(req, action)
303
+ return handler(req, reply, ctx)
198
304
  }
199
- } catch (err) {
200
- return reply.type('application/json').code(500).send({ error: err.message || err })
305
+ })
306
+
307
+ const checks = {
308
+ get: (req) => ctx.checkId(req.params.id),
309
+ update: (req) => ctx.checkId(req.params.id),
310
+ delete: (req) => ctx.checkId(req.params.id),
311
+ create: ({ body }) => {
312
+ if (!Array.isArray(body)) {
313
+ if (!isPlainObject(body)) throw new CrudifyError(400, 'Invalid request body', { code: 'invalid_body' })
314
+ } else if (!c.bulk) throw new CrudifyError(400, 'Bulk create is disabled', { code: 'invalid_body' })
315
+ else if (!body.length) throw new CrudifyError(400, 'Empty bulk body', { code: 'invalid_body' })
316
+ else if (body.length > c.bulk.max) throw new CrudifyError(413, `Too many items (max ${c.bulk.max})`)
201
317
  }
202
- })
318
+ }
319
+
320
+ for (const verb of c.methods) {
321
+ const [method, path] = ROUTES[verb]
322
+ route(method, path, { action: verb, schema: c.schemas[verb] }, c.handlers[verb] ?? builtin[verb], checks[verb])
323
+ }
324
+
325
+ const api = Object.fromEntries(['get', 'post', 'put', 'patch', 'delete'].map(m =>
326
+ [m, (path, a, b) => b ? route(m.toUpperCase(), path, a, b) : route(m.toUpperCase(), path, {}, a)]))
327
+ await c.routes?.(api, ctx)
328
+
329
+ for (const [name, { handler, method = 'POST', path = `/:id/${name}`, load = true, action = name, ...rest }] of Object.entries(c.actions)) {
330
+ route(method.toUpperCase(), path, { ...rest, action, load }, handler)
331
+ }
203
332
 
204
- if (opts.additionalRoutes) {
205
- opts.additionalRoutes(fastify, opts)
206
- }
207
-
208
- next()
209
333
  }
210
334
 
335
+ Crudify[Symbol.for('plugin-meta')] = { name: 'crudify-mongo', fastify: '4.x || 5.x' }
336
+ Crudify[Symbol.for('fastify.display-name')] = 'crudify-mongo'
337
+
211
338
  export {
212
- Crudify
339
+ Crudify,
340
+ composeFilter,
341
+ duplicateKeyFields,
342
+ isDuplicateKeyError,
343
+ toErrorResponse
213
344
  }
package/package.json CHANGED
@@ -1,12 +1,34 @@
1
1
  {
2
2
  "name": "@aaroncadillac/crudify-mongo",
3
- "version": "1.8.2",
4
- "description": "An automatic CRUD generator for fastify",
5
- "main": "crudify.js",
3
+ "version": "3.1.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": "yarn node --experimental-vm-modules $(yarn bin jest)"
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": "^4.28.1",
58
+ "fastify": "^5.0.0",
59
+ "fastify4": "npm:fastify@^4.29.0",
20
60
  "jest": "^29.7.0",
21
- "mongoose": "^8.5.1",
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
  }