@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/crudify.js CHANGED
@@ -1,213 +1,341 @@
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
- pagination: {
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
+ 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
- return newOpts
83
- } else {
84
- return opts
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
- 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.pagination ||= '{"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
- })
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
- 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
- }
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
- 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)
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 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
- }
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
- 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 })
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
- 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)
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
- } catch (err) {
200
- return reply.type('application/json').code(500).send({ error: err.message || err })
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
- if (opts.additionalRoutes) {
205
- opts.additionalRoutes(fastify, opts)
206
- }
207
-
208
- next()
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": "1.8.0",
4
- "description": "An automatic CRUD generator for fastify",
5
- "main": "crudify.js",
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": "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
  }