@aaroncadillac/crudify-mongo 1.8.2 → 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 +255 -203
- 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/sanitizer.js
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// Query and body sanitizer for Crudify. Dependency-free and usable on its own
|
|
2
|
+
// (`@aaroncadillac/crudify-mongo/sanitizer`) from custom handlers.
|
|
3
|
+
//
|
|
4
|
+
// `createSanitizer(config)` compiles the allowlists once and returns
|
|
5
|
+
// { filter, options, update, create }. Every function builds a NEW object from
|
|
6
|
+
// the input (which is never mutated) and throws a 400 `CrudifyError` on
|
|
7
|
+
// anything outside the allowed shape.
|
|
8
|
+
|
|
9
|
+
class CrudifyError extends Error {
|
|
10
|
+
constructor (status, message, { code, details } = {}) {
|
|
11
|
+
super(message)
|
|
12
|
+
this.name = 'CrudifyError'
|
|
13
|
+
this.statusCode = status
|
|
14
|
+
if (code) this.code = code
|
|
15
|
+
if (details) this.details = details
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const SCALAR_OPS = new Set(['$eq', '$ne', '$gt', '$gte', '$lt', '$lte'])
|
|
20
|
+
const LIST_OPS = new Set(['$in', '$nin', '$all'])
|
|
21
|
+
const LOGICAL_OPS = new Set(['$or', '$and', '$nor'])
|
|
22
|
+
const SUPPORTED_OPS = new Set([...SCALAR_OPS, ...LIST_OPS, ...LOGICAL_OPS, '$exists', '$size'])
|
|
23
|
+
// Never configurable: arbitrary code, client regex (ReDoS) or text search.
|
|
24
|
+
const FORBIDDEN_OPS = new Set(['$where', '$expr', '$function', '$accumulator', '$regex', '$text', '$jsonSchema', '$elemMatch'])
|
|
25
|
+
const DEFAULT_OPERATORS = Object.freeze(['$eq', '$ne', '$in', '$nin', '$gt', '$gte', '$lt', '$lte', '$exists', '$or', '$and'])
|
|
26
|
+
|
|
27
|
+
const ALWAYS_DENIED = ['_id', '__v', 'createdAt', 'updatedAt']
|
|
28
|
+
const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
|
|
29
|
+
const SORT_FIELD_RE = /^[A-Za-z0-9_.]+$/
|
|
30
|
+
|
|
31
|
+
const DEFAULTS = {
|
|
32
|
+
operators: DEFAULT_OPERATORS,
|
|
33
|
+
maxDepth: 4,
|
|
34
|
+
maxKeys: 100,
|
|
35
|
+
maxArray: 1000,
|
|
36
|
+
maxBranches: 20,
|
|
37
|
+
search: 'contains', // '/text/' -> /text/i ('contains'), /^text/ ('prefix') or left as a string (false)
|
|
38
|
+
maxSearchLength: 64,
|
|
39
|
+
filterable: null, // allowlist of root fields usable in filters and sort
|
|
40
|
+
defaultLimit: 50,
|
|
41
|
+
maxLimit: 100,
|
|
42
|
+
maxSort: 5,
|
|
43
|
+
populate: [], // paths a client may ask for in `options.populate`
|
|
44
|
+
writable: null, // allowlist of root fields for create/update
|
|
45
|
+
readonly: [], // root fields never written by the client
|
|
46
|
+
hidden: [] // never filterable, sortable or populable
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
|
|
50
|
+
const isPrimitive = (v) => v === null || typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean'
|
|
51
|
+
const toInt = (v) => { const n = Math.floor(Number(v)); return Number.isSafeInteger(n) ? n : 0 }
|
|
52
|
+
const bad = (message, code = 'invalid_query') => new CrudifyError(400, message, { code })
|
|
53
|
+
const escapeRegex = (str) => str.replace(/[.*+?^${}()|[\]\\/-]/g, '\\$&')
|
|
54
|
+
|
|
55
|
+
// Parses a JSON query param exactly once; a repeated param arrives as an array.
|
|
56
|
+
const parseJson = (raw, name) => {
|
|
57
|
+
if (raw === undefined || raw === '') return {}
|
|
58
|
+
if (typeof raw !== 'string') throw bad(`Invalid ${name}`)
|
|
59
|
+
let value
|
|
60
|
+
try { value = JSON.parse(raw) } catch { throw bad(`Invalid ${name} JSON`) }
|
|
61
|
+
if (!isPlainObject(value)) throw bad(`Invalid ${name}`)
|
|
62
|
+
return value
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const assertNoOperators = (value, path) => {
|
|
66
|
+
if (Array.isArray(value)) return value.forEach(item => assertNoOperators(item, path))
|
|
67
|
+
if (!isPlainObject(value)) return
|
|
68
|
+
for (const key in value) {
|
|
69
|
+
if (key[0] === '$' || UNSAFE_KEYS.has(key)) throw bad(`Operator not allowed inside "${path}"`, 'invalid_body')
|
|
70
|
+
assertNoOperators(value[key], path)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const compile = (cfg = {}) => {
|
|
75
|
+
const c = {}
|
|
76
|
+
for (const key in DEFAULTS) c[key] = cfg[key] ?? DEFAULTS[key]
|
|
77
|
+
for (const op of c.operators) {
|
|
78
|
+
if (FORBIDDEN_OPS.has(op) || !SUPPORTED_OPS.has(op)) throw new TypeError(`Crudify sanitizer: operator not supported: ${op}`)
|
|
79
|
+
}
|
|
80
|
+
const ops = new Set(c.operators)
|
|
81
|
+
const hidden = new Set(c.hidden)
|
|
82
|
+
const filterable = c.filterable && new Set(c.filterable)
|
|
83
|
+
const writable = c.writable && new Set(c.writable)
|
|
84
|
+
const populable = new Set(c.populate)
|
|
85
|
+
const denied = new Set([...ALWAYS_DENIED, ...c.readonly])
|
|
86
|
+
const toSearch = c.search === 'prefix' ? (s) => new RegExp('^' + escapeRegex(s))
|
|
87
|
+
: c.search === 'contains' ? (s) => new RegExp(escapeRegex(s), 'i') : null
|
|
88
|
+
let budget = 0 // filter() is synchronous, so one counter per sanitizer is safe
|
|
89
|
+
|
|
90
|
+
const rootOf = (field) => {
|
|
91
|
+
const parts = field.split('.')
|
|
92
|
+
if (field.includes('$') || parts.some(p => !p || UNSAFE_KEYS.has(p))) throw bad(`Invalid field: ${field}`)
|
|
93
|
+
return parts[0]
|
|
94
|
+
}
|
|
95
|
+
const checkField = (field) => {
|
|
96
|
+
const root = rootOf(field)
|
|
97
|
+
if (hidden.has(root) || (filterable && !filterable.has(root))) throw bad(`Field not allowed: ${field}`)
|
|
98
|
+
}
|
|
99
|
+
const isList = (v) => Array.isArray(v) && v.length <= c.maxArray && v.every(isPrimitive)
|
|
100
|
+
|
|
101
|
+
// '/text/' is the search convention: a LITERAL match, never a client regex.
|
|
102
|
+
const convert = (v) => {
|
|
103
|
+
if (toSearch && typeof v === 'string' && v.length >= 2 && v[0] === '/' && v[v.length - 1] === '/') {
|
|
104
|
+
if (v.length - 2 > c.maxSearchLength) throw bad('Search text too long')
|
|
105
|
+
return toSearch(v.slice(1, -1))
|
|
106
|
+
}
|
|
107
|
+
return v
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const walkValue = (field, value) => {
|
|
111
|
+
if (isPrimitive(value)) return convert(value)
|
|
112
|
+
if (Array.isArray(value)) {
|
|
113
|
+
if (!isList(value)) throw bad(`Invalid value for "${field}"`)
|
|
114
|
+
return value
|
|
115
|
+
}
|
|
116
|
+
if (!isPlainObject(value)) throw bad(`Invalid value for "${field}"`)
|
|
117
|
+
const out = {}
|
|
118
|
+
for (const op in value) {
|
|
119
|
+
const v = value[op]
|
|
120
|
+
if (!ops.has(op) || LOGICAL_OPS.has(op)) throw bad(`Operator not allowed: ${op}`)
|
|
121
|
+
if (SCALAR_OPS.has(op) ? !isPrimitive(v)
|
|
122
|
+
: LIST_OPS.has(op) ? !isList(v)
|
|
123
|
+
: op === '$exists' ? typeof v !== 'boolean'
|
|
124
|
+
: !Number.isInteger(v) || v < 0) throw bad(`Invalid value for ${op}`)
|
|
125
|
+
out[op] = v
|
|
126
|
+
}
|
|
127
|
+
return out
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const walk = (node, depth) => {
|
|
131
|
+
if (depth > c.maxDepth) throw bad('Filter too deeply nested')
|
|
132
|
+
if (!isPlainObject(node)) throw bad('Filter must be an object')
|
|
133
|
+
const out = {}
|
|
134
|
+
for (const key in node) {
|
|
135
|
+
if (++budget > c.maxKeys) throw bad('Filter too large')
|
|
136
|
+
const value = node[key]
|
|
137
|
+
if (key[0] === '$') {
|
|
138
|
+
if (!LOGICAL_OPS.has(key) || !ops.has(key)) throw bad(`Operator not allowed: ${key}`)
|
|
139
|
+
if (!Array.isArray(value) || !value.length || value.length > c.maxBranches) throw bad(`${key} expects a non-empty array`)
|
|
140
|
+
out[key] = value.map(child => walk(child, depth + 1))
|
|
141
|
+
} else {
|
|
142
|
+
checkField(key) // before assigning: rejects `__proto__`
|
|
143
|
+
out[key] = walkValue(key, value)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return out
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const direction = (d) => {
|
|
150
|
+
if (d === 1 || d === 'asc' || d === 'ascending') return 1
|
|
151
|
+
if (d === -1 || d === 'desc' || d === 'descending') return -1
|
|
152
|
+
throw bad('Invalid sort direction')
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const parseSort = (raw) => {
|
|
156
|
+
const out = {}
|
|
157
|
+
let count = 0
|
|
158
|
+
const add = (field, dir) => {
|
|
159
|
+
if (!SORT_FIELD_RE.test(field)) throw bad('Invalid sort')
|
|
160
|
+
checkField(field)
|
|
161
|
+
if (++count > c.maxSort) throw bad('Too many sort fields')
|
|
162
|
+
out[field] = dir
|
|
163
|
+
}
|
|
164
|
+
if (typeof raw === 'string') {
|
|
165
|
+
for (const part of raw.split(/\s+/)) if (part) add(part.replace(/^[-+]/, ''), part[0] === '-' ? -1 : 1)
|
|
166
|
+
} else if (isPlainObject(raw)) {
|
|
167
|
+
for (const field in raw) add(field, direction(raw[field]))
|
|
168
|
+
} else throw bad('Invalid sort')
|
|
169
|
+
return out
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Only page / limit / sort (+ allowlisted populate) survive. `select`,
|
|
173
|
+
// `projection`, `lean`, `collation` and `pagination:false` are ignored.
|
|
174
|
+
const options = (raw) => {
|
|
175
|
+
const src = isPlainObject(raw) ? raw : {}
|
|
176
|
+
const asked = toInt(src.limit)
|
|
177
|
+
const out = {
|
|
178
|
+
page: Math.max(1, toInt(src.page)),
|
|
179
|
+
limit: Math.min(c.maxLimit, asked > 0 ? asked : c.defaultLimit),
|
|
180
|
+
pagination: true
|
|
181
|
+
}
|
|
182
|
+
if (src.sort !== undefined && src.sort !== null && src.sort !== '') out.sort = parseSort(src.sort)
|
|
183
|
+
if (populable.size && src.populate) {
|
|
184
|
+
const wanted = typeof src.populate === 'string' ? src.populate.split(/\s+/) : Array.isArray(src.populate) ? src.populate : []
|
|
185
|
+
const allowed = wanted.filter(p => populable.has(p))
|
|
186
|
+
if (allowed.length) out.populate = allowed
|
|
187
|
+
}
|
|
188
|
+
return out
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Protected roots are dropped silently (forms send the whole document back).
|
|
192
|
+
const accepts = (field, deny) => {
|
|
193
|
+
const root = rootOf(field)
|
|
194
|
+
return !deny.has(root) && (!writable || writable.has(root))
|
|
195
|
+
}
|
|
196
|
+
const denySet = (extraDeny) => extraDeny?.length ? new Set([...denied, ...extraDeny]) : denied
|
|
197
|
+
|
|
198
|
+
// Returns a FLAT update (plain keys, dotted paths allowed, optional $unset):
|
|
199
|
+
// Mongoose wraps it in $set, and model hooks reading `update.x` keep working.
|
|
200
|
+
const update = (body, extraDeny) => {
|
|
201
|
+
if (!isPlainObject(body)) throw bad('Invalid request body', 'invalid_body')
|
|
202
|
+
const deny = denySet(extraDeny)
|
|
203
|
+
const out = {}
|
|
204
|
+
const unset = {}
|
|
205
|
+
const put = (field, value) => { if (accepts(field, deny)) { assertNoOperators(value, field); out[field] = value } }
|
|
206
|
+
for (const key in body) {
|
|
207
|
+
const value = body[key]
|
|
208
|
+
if (key === '$set') {
|
|
209
|
+
if (!isPlainObject(value)) throw bad('Invalid $set', 'invalid_body')
|
|
210
|
+
for (const field in value) put(field, value[field])
|
|
211
|
+
} else if (key === '$unset') {
|
|
212
|
+
if (!isPlainObject(value)) throw bad('Invalid $unset', 'invalid_body')
|
|
213
|
+
for (const field in value) if (accepts(field, deny)) unset[field] = ''
|
|
214
|
+
} else if (key[0] === '$') {
|
|
215
|
+
throw bad(`Operator not allowed: ${key}`, 'invalid_body')
|
|
216
|
+
} else put(key, value)
|
|
217
|
+
}
|
|
218
|
+
if (Object.keys(unset).length) out.$unset = unset
|
|
219
|
+
if (!Object.keys(out).length) throw bad('No valid fields to update', 'invalid_body')
|
|
220
|
+
return out
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const create = (item, extraDeny) => {
|
|
224
|
+
if (!isPlainObject(item)) throw bad('Invalid request body', 'invalid_body')
|
|
225
|
+
const deny = denySet(extraDeny)
|
|
226
|
+
const out = {}
|
|
227
|
+
for (const key in item) {
|
|
228
|
+
if (!accepts(key, deny)) continue
|
|
229
|
+
assertNoOperators(item[key], key)
|
|
230
|
+
out[key] = item[key]
|
|
231
|
+
}
|
|
232
|
+
return out
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return { filter: (raw) => { budget = 0; return walk(raw ?? {}, 1) }, options, update, create }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const createSanitizer = (config) => Object.freeze(compile(config))
|
|
239
|
+
|
|
240
|
+
// Standalone helpers: compiled once per config object (or the defaults).
|
|
241
|
+
const compiled = new WeakMap()
|
|
242
|
+
const defaults = createSanitizer()
|
|
243
|
+
const forConfig = (config) => {
|
|
244
|
+
if (!config) return defaults
|
|
245
|
+
if (!compiled.has(config)) compiled.set(config, createSanitizer(config))
|
|
246
|
+
return compiled.get(config)
|
|
247
|
+
}
|
|
248
|
+
const sanitizeFilter = (raw, config) => forConfig(config).filter(raw)
|
|
249
|
+
const sanitizeOptions = (raw, config) => forConfig(config).options(raw)
|
|
250
|
+
const sanitizeUpdate = (body, config, extraDeny) => forConfig(config).update(body, extraDeny)
|
|
251
|
+
const sanitizeCreate = (item, config, extraDeny) => forConfig(config).create(item, extraDeny)
|
|
252
|
+
|
|
253
|
+
export {
|
|
254
|
+
CrudifyError,
|
|
255
|
+
DEFAULT_OPERATORS,
|
|
256
|
+
createSanitizer,
|
|
257
|
+
escapeRegex,
|
|
258
|
+
isPlainObject,
|
|
259
|
+
parseJson,
|
|
260
|
+
sanitizeCreate,
|
|
261
|
+
sanitizeFilter,
|
|
262
|
+
sanitizeOptions,
|
|
263
|
+
sanitizeUpdate
|
|
264
|
+
}
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
name : Publish
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
push:
|
|
5
|
-
branches:
|
|
6
|
-
- master
|
|
7
|
-
|
|
8
|
-
jobs:
|
|
9
|
-
publish:
|
|
10
|
-
runs-on: ubuntu-latest
|
|
11
|
-
permissions:
|
|
12
|
-
contents: write
|
|
13
|
-
id-token: write
|
|
14
|
-
steps:
|
|
15
|
-
- name: Checkout
|
|
16
|
-
id: checkout
|
|
17
|
-
uses: actions/checkout@v4
|
|
18
|
-
- name: Installing Node
|
|
19
|
-
uses: actions/setup-node@v4
|
|
20
|
-
with:
|
|
21
|
-
node-version: 20
|
|
22
|
-
registry-url: 'https://registry.npmjs.org'
|
|
23
|
-
- name: Config git user & email
|
|
24
|
-
run: |
|
|
25
|
-
git config user.name "$(git log -n 1 --pretty=format:%an)"
|
|
26
|
-
git config user.email "$(git log -n 1 --pretty=format:%ae)"
|
|
27
|
-
- name: Run my Action
|
|
28
|
-
id: run-action
|
|
29
|
-
uses: iuccio/npm-semver-publish-action@v1.0.0
|
|
30
|
-
with:
|
|
31
|
-
target-branch: master
|
|
32
|
-
provenance: true
|
|
33
|
-
env:
|
|
34
|
-
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
name: Run Tests
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
pull_request:
|
|
5
|
-
branches:
|
|
6
|
-
- master
|
|
7
|
-
|
|
8
|
-
jobs:
|
|
9
|
-
test:
|
|
10
|
-
runs-on: ubuntu-latest
|
|
11
|
-
steps:
|
|
12
|
-
- name: Checkout repository
|
|
13
|
-
uses: actions/checkout@v4
|
|
14
|
-
- name: Set up Node.js
|
|
15
|
-
uses: actions/setup-node@v4
|
|
16
|
-
with:
|
|
17
|
-
node-version: '20'
|
|
18
|
-
- name: Install dependencies
|
|
19
|
-
run: yarn
|
|
20
|
-
- name: Run Jest tests
|
|
21
|
-
run: yarn test
|
package/example/db-connection.js
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import mongoose from 'mongoose';
|
|
2
|
-
|
|
3
|
-
const dbConnection = async ( fastify ) => {
|
|
4
|
-
try {
|
|
5
|
-
await mongoose.connect(`${process.env.MONGODB_URL}/${process.env.MONGO_DATABASE}${process.env.MONGO_OPTIONS && `?${process.env.MONGO_OPTIONS}`}`);
|
|
6
|
-
fastify.log.info('Connected to database');
|
|
7
|
-
}
|
|
8
|
-
catch (error) {
|
|
9
|
-
fastify.log.error('Error connecting to database');
|
|
10
|
-
}
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export {
|
|
14
|
-
dbConnection
|
|
15
|
-
}
|
package/example/models/posts.js
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
import mongoose from 'mongoose';
|
|
2
|
-
import paginate from 'mongoose-paginate-v2';
|
|
3
|
-
|
|
4
|
-
const Schema = mongoose.Schema;
|
|
5
|
-
|
|
6
|
-
const postsSchema = new Schema({
|
|
7
|
-
title: {
|
|
8
|
-
type: String,
|
|
9
|
-
required: true
|
|
10
|
-
},
|
|
11
|
-
content: {
|
|
12
|
-
type: String,
|
|
13
|
-
required: true
|
|
14
|
-
},
|
|
15
|
-
author: {
|
|
16
|
-
type: Schema.Types.ObjectId,
|
|
17
|
-
ref: 'Users',
|
|
18
|
-
required: true
|
|
19
|
-
}
|
|
20
|
-
}, {
|
|
21
|
-
timestamps: true,
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
postsSchema.plugin(paginate);
|
|
25
|
-
|
|
26
|
-
const PostsModel = mongoose.model('Users', postsSchema);
|
|
27
|
-
|
|
28
|
-
export {
|
|
29
|
-
PostsModel
|
|
30
|
-
}
|
package/example/models/users.js
DELETED
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
import mongoose from 'mongoose';
|
|
2
|
-
import paginate from 'mongoose-paginate-v2';
|
|
3
|
-
|
|
4
|
-
const Schema = mongoose.Schema;
|
|
5
|
-
|
|
6
|
-
const userSchema = new Schema({
|
|
7
|
-
name: {
|
|
8
|
-
type: String,
|
|
9
|
-
required: true
|
|
10
|
-
},
|
|
11
|
-
lastName: {
|
|
12
|
-
type: String,
|
|
13
|
-
required: true
|
|
14
|
-
},
|
|
15
|
-
email: {
|
|
16
|
-
type: String,
|
|
17
|
-
required: true
|
|
18
|
-
},
|
|
19
|
-
username: {
|
|
20
|
-
type: String,
|
|
21
|
-
required: true
|
|
22
|
-
},
|
|
23
|
-
phone: {
|
|
24
|
-
type: String,
|
|
25
|
-
required: true
|
|
26
|
-
}
|
|
27
|
-
}, {
|
|
28
|
-
timestamps: true
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
userSchema.plugin(paginate);
|
|
32
|
-
|
|
33
|
-
const UserModel = mongoose.model('Users', userSchema);
|
|
34
|
-
|
|
35
|
-
export {
|
|
36
|
-
UserModel
|
|
37
|
-
}
|
package/example/routes.js
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import { UserModel } from "./models/users.js"
|
|
2
|
-
|
|
3
|
-
const API_PREFIX = '/api'
|
|
4
|
-
|
|
5
|
-
const routes = ( fastify, opts, done ) => {
|
|
6
|
-
fastify.register(AutoCrud, {
|
|
7
|
-
url: `${API_PREFIX}/users`,
|
|
8
|
-
Model: UserModel
|
|
9
|
-
})
|
|
10
|
-
done()
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export {
|
|
14
|
-
routes
|
|
15
|
-
}
|
package/example/server.js
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
import 'dotenv/config';
|
|
2
|
-
import Fastify from 'fastify'
|
|
3
|
-
import { dbConnection } from './db-connection.js';
|
|
4
|
-
import { routes } from './routes.js';
|
|
5
|
-
|
|
6
|
-
const fastify = Fastify({
|
|
7
|
-
logger: true
|
|
8
|
-
})
|
|
9
|
-
|
|
10
|
-
fastify.register(await dbConnection)
|
|
11
|
-
fastify.register(routes)
|
|
12
|
-
|
|
13
|
-
fastify.listen({ port: process.env.PORT || 3000, host: process.env.HOST || 'localhost' }, function (err, address) {
|
|
14
|
-
if (err) {
|
|
15
|
-
fastify.log.error(err)
|
|
16
|
-
process.exit(1)
|
|
17
|
-
}
|
|
18
|
-
})
|
|
19
|
-
|
|
20
|
-
|
package/jest-mongodb-config.js
DELETED
package/jest.config.js
DELETED
package/test/crudify.test.js
DELETED
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
import fastify from 'fastify';
|
|
2
|
-
import { Crudify } from '../crudify';
|
|
3
|
-
import mongoose, { connect, Schema, model } from 'mongoose';
|
|
4
|
-
import paginate from 'mongoose-paginate-v2';
|
|
5
|
-
import request from 'supertest';
|
|
6
|
-
|
|
7
|
-
let app;
|
|
8
|
-
let UserModel;
|
|
9
|
-
let connection;
|
|
10
|
-
|
|
11
|
-
beforeAll(async () => {
|
|
12
|
-
app = fastify();
|
|
13
|
-
await connect(global.__MONGO_URI__);
|
|
14
|
-
connection = mongoose.connection
|
|
15
|
-
const userSchema = new Schema({ name: String, age: Number, email: String });
|
|
16
|
-
userSchema.plugin(paginate);
|
|
17
|
-
UserModel = model('User', userSchema);
|
|
18
|
-
app.register(Crudify, { url: '/users', Model: UserModel });
|
|
19
|
-
await app.ready();
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
afterAll(async () => {
|
|
23
|
-
await connection.dropDatabase();
|
|
24
|
-
await connection.close();
|
|
25
|
-
await app.close();
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
describe('CRUD Operations', () => {
|
|
29
|
-
it('should create a user', async () => {
|
|
30
|
-
const response = await request(app.server)
|
|
31
|
-
.post('/users')
|
|
32
|
-
.send({ name: 'John', age: 30, email: 'john@example.com' });
|
|
33
|
-
expect(response.status).toBe(201);
|
|
34
|
-
expect(response.body).toHaveProperty('_id');
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it('should get all users', async () => {
|
|
38
|
-
const response = await request(app.server).get('/users');
|
|
39
|
-
expect(response.status).toBe(200);
|
|
40
|
-
expect(response.body.docs).toBeInstanceOf(Array);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it('should get a user by id', async () => {
|
|
44
|
-
const newUser = await request(app.server)
|
|
45
|
-
.post('/users')
|
|
46
|
-
.send({ name: 'Jane', age: 25, email: 'jane@example.com' });
|
|
47
|
-
const userId = newUser.body._id;
|
|
48
|
-
const response = await request(app.server).get(`/users/${userId}`);
|
|
49
|
-
expect(response.status).toBe(200);
|
|
50
|
-
expect(response.body).toHaveProperty('_id', userId);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it('should update a user', async () => {
|
|
54
|
-
const newUser = await request(app.server)
|
|
55
|
-
.post('/users')
|
|
56
|
-
.send({ name: 'Mark', age: 28, email: 'mark@example.com' });
|
|
57
|
-
const userId = newUser.body._id;
|
|
58
|
-
const response = await request(app.server)
|
|
59
|
-
.put(`/users/${userId}`)
|
|
60
|
-
.send({ age: 29 });
|
|
61
|
-
expect(response.status).toBe(200);
|
|
62
|
-
expect(response.body).toHaveProperty('age', 29);
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
it('should delete a user', async () => {
|
|
66
|
-
const newUser = await request(app.server)
|
|
67
|
-
.post('/users')
|
|
68
|
-
.send({ name: 'Paul', age: 35, email: 'paul@example.com' });
|
|
69
|
-
const userId = newUser.body._id;
|
|
70
|
-
const response = await request(app.server).delete(`/users/${userId}`);
|
|
71
|
-
expect(response.status).toBe(200);
|
|
72
|
-
});
|
|
73
|
-
});
|