@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/CHANGELOG.md +133 -0
- package/README.md +295 -198
- package/crudify.js +314 -183
- 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/README.md
CHANGED
|
@@ -2,282 +2,379 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://opensource.org/licenses/MPL-2.0)
|
|
4
4
|

|
|
5
|
+

|
|
6
|
+

|
|
5
7
|

|
|
6
8
|
|
|
9
|
+
A CRUD generator for [Fastify](https://fastify.dev) and [Mongoose](https://mongoosejs.com) that is **safe to expose by default**: client queries are sanitized, results are bounded, errors never leak internals, and every read and write can be pinned to a server-side scope. It is small (two files, no runtime dependencies) and extensible through hooks and custom routes.
|
|
7
10
|
|
|
8
|
-
|
|
11
|
+
> Upgrading from 1.x? Read the [migration guide](CHANGELOG.md#migration-guide-1x--20).
|
|
9
12
|
|
|
10
|
-
|
|
13
|
+
- [Install](#install)
|
|
14
|
+
- [Quick start](#quick-start)
|
|
15
|
+
- [Secure by default](#secure-by-default)
|
|
16
|
+
- [Authentication and authorization](#authentication-and-authorization)
|
|
17
|
+
- [Query language](#query-language)
|
|
18
|
+
- [Options](#options)
|
|
19
|
+
- [Errors](#errors)
|
|
20
|
+
- [Scope and authorize](#scope-and-authorize)
|
|
21
|
+
- [Hooks and request order](#hooks-and-request-order)
|
|
22
|
+
- [Custom routes, actions and overrides](#custom-routes-actions-and-overrides)
|
|
23
|
+
- [Recipes](#recipes)
|
|
24
|
+
- [The sanitizer on its own](#the-sanitizer-on-its-own)
|
|
25
|
+
- [JSON Schema and Swagger](#json-schema-and-swagger)
|
|
26
|
+
- [Performance](#performance)
|
|
11
27
|
|
|
12
|
-
|
|
13
|
-
- [Automatic CRUD generation](#automatic-crud-generation)
|
|
14
|
-
- [Custom routes](#custom-routes)
|
|
15
|
-
- [Getting started](#getting-started)
|
|
16
|
-
- [Installation](#installation)
|
|
17
|
-
- [Usage](#usage)
|
|
18
|
-
- [Aknowledgements](#aknowledgements)
|
|
19
|
-
- [License](#license)
|
|
28
|
+
## Install
|
|
20
29
|
|
|
21
|
-
|
|
30
|
+
```bash
|
|
31
|
+
yarn add @aaroncadillac/crudify-mongo
|
|
32
|
+
```
|
|
22
33
|
|
|
23
|
-
|
|
24
|
-
- [Mongoose](https://mongoosejs.com) v8 or higher
|
|
25
|
-
- [mongoose-paginate-v2](https://www.npmjs.com/package/mongoose-paginate-v2) v1.8 or higher
|
|
26
|
-
|
|
27
|
-
## Features
|
|
34
|
+
Requires Node >= 20 (Node 22, the current LTS, is recommended), Fastify 4 or 5, Mongoose 8 and, for the list route, [mongoose-paginate-v2](https://www.npmjs.com/package/mongoose-paginate-v2) >= 1.8. They are peer dependencies: Crudify imports none of them.
|
|
28
35
|
|
|
29
|
-
|
|
36
|
+
## Quick start
|
|
30
37
|
|
|
31
|
-
|
|
38
|
+
```js
|
|
39
|
+
import Fastify from 'fastify'
|
|
40
|
+
import mongoose from 'mongoose'
|
|
41
|
+
import paginate from 'mongoose-paginate-v2'
|
|
42
|
+
import { Crudify } from '@aaroncadillac/crudify-mongo'
|
|
32
43
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
| `PUT /:id` | Update a document by id | `Not-Found validation` `Schema validation`|Activated by default|
|
|
39
|
-
| `DELETE /:id` | Delete a document by id | `Not-Found validation` |Activated by default|
|
|
44
|
+
const userSchema = new mongoose.Schema({
|
|
45
|
+
name: { type: String, required: true },
|
|
46
|
+
email: { type: String, required: true }
|
|
47
|
+
}, { timestamps: true })
|
|
48
|
+
userSchema.plugin(paginate) // required by the list route
|
|
40
49
|
|
|
41
|
-
|
|
50
|
+
const User = mongoose.model('Users', userSchema)
|
|
42
51
|
|
|
43
|
-
|
|
52
|
+
await mongoose.connect(process.env.MONGODB_URL)
|
|
44
53
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
url: '/users',
|
|
49
|
-
Model: UserModel,
|
|
50
|
-
additionalRoutes: [
|
|
51
|
-
{
|
|
52
|
-
method: 'GET',
|
|
53
|
-
url: '/:id/light-details',
|
|
54
|
-
handler: LightDetailsUserHandler
|
|
55
|
-
},
|
|
56
|
-
...
|
|
57
|
-
]
|
|
58
|
-
})
|
|
54
|
+
const app = Fastify({ logger: true })
|
|
55
|
+
app.register(Crudify, { url: '/users', Model: User, scope: false }) // see "Scope and authorize"
|
|
56
|
+
await app.listen({ port: 3000 })
|
|
59
57
|
```
|
|
60
58
|
|
|
61
|
-
|
|
59
|
+
| Route | Does | Built in |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| `GET /users` | paginated list | sanitized `filters` / `options`, limit cap |
|
|
62
|
+
| `GET /users/:id` | one document | 404 when missing or out of scope |
|
|
63
|
+
| `POST /users` | create one document, or an array (bulk) | schema validation, save hooks run |
|
|
64
|
+
| `PUT /users/:id` | update fields | only your writable fields, validators run |
|
|
65
|
+
| `DELETE /users/:id` | delete | 404 when missing or out of scope |
|
|
62
66
|
|
|
63
|
-
|
|
67
|
+
The URLs and response shapes are the same as in 1.x. A runnable example with authentication, two tenants and a custom action lives in [`example/`](example/).
|
|
64
68
|
|
|
65
|
-
|
|
69
|
+
## Secure by default
|
|
66
70
|
|
|
67
|
-
|
|
71
|
+
Without any configuration:
|
|
68
72
|
|
|
69
|
-
|
|
70
|
-
|
|
73
|
+
- `filters` accept a short allowlist of operators with bounded depth and size. `$where`, `$expr`, `$function`, `$regex`, `$text` and friends are refused.
|
|
74
|
+
- `"/text/"` searches are **escaped literals**, never a client-supplied regex, so they cannot be used for ReDoS.
|
|
75
|
+
- `options` only honour `page`, `limit` and `sort`. `limit` defaults to 50 and is capped at 100; `populate`, `select`, `lean` and `pagination: false` from the client are ignored.
|
|
76
|
+
- Writes drop `_id`, `__v` and the timestamps, accept no update operators besides `$set` / `$unset`, and refuse operators nested inside values.
|
|
77
|
+
- Fields with `select: false` in your schema cannot be filtered or sorted on (it would be a search oracle).
|
|
78
|
+
- Bulk creation goes through `Model.create`, so `pre('save')` / `post('save')` hooks always run, and an invalid item inserts nothing.
|
|
79
|
+
- Errors are mapped to a fixed shape. Duplicate keys become a generic `409`; unexpected errors become `500 Internal error` (the detail goes to your logs, never to the client).
|
|
80
|
+
- Invalid ids answer `404` without touching the database.
|
|
71
81
|
|
|
72
|
-
|
|
73
|
-
import Fastify from 'fastify'
|
|
74
|
-
import { dbConnection } from './db-connection.js';
|
|
75
|
-
import { routes } from './routes.js';
|
|
76
|
-
import * as Swagger from '@fastify/swagger'
|
|
77
|
-
import * as SwaggerUI from '@fastify/swagger-ui'
|
|
82
|
+
What it does **not** do on its own is decide *who* may see *which* documents. That is what `authorize` and `scope` are for.
|
|
78
83
|
|
|
79
|
-
|
|
80
|
-
logger: true
|
|
81
|
-
})
|
|
84
|
+
## Authentication and authorization
|
|
82
85
|
|
|
83
|
-
fastify.
|
|
84
|
-
process.env.NODE_ENV == 'development' && fastify.register(Swagger) && await fastify.register(SwaggerUI, {
|
|
85
|
-
routePrefix: '/docs',
|
|
86
|
-
uiConfig: {
|
|
87
|
-
docExpansion: 'list',
|
|
88
|
-
deepLinking: true
|
|
89
|
-
},
|
|
90
|
-
uiHooks: {
|
|
91
|
-
onRequest: function (request, reply, next) { next() },
|
|
92
|
-
preHandler: function (request, reply, next) { next() }
|
|
93
|
-
},
|
|
94
|
-
staticCSP: true,
|
|
95
|
-
transformStaticCSP: (header) => header,
|
|
96
|
-
transformSpecification: (swaggerObject, request, reply) => { return swaggerObject },
|
|
97
|
-
transformSpecificationClone: true
|
|
98
|
-
})
|
|
86
|
+
Crudify does **not** authenticate users and does not include any permission system. It stays out of that on purpose and gives you two seams so any tool can plug in: [`@fastify/auth`](https://github.com/fastify/fastify-auth), JWT plugins, sessions, Casbin, your own checks.
|
|
99
87
|
|
|
100
|
-
|
|
88
|
+
1. Authenticate with a Fastify hook or `routeOptions.preHandler` (this is where `req.user` gets set).
|
|
89
|
+
2. `authorize(req, action)` answers *may this user do this action?* and throws (or returns `false`) to deny.
|
|
90
|
+
3. `scope(req, action)` answers *which documents is this user limited to?* and returns a Mongo filter.
|
|
101
91
|
|
|
102
|
-
|
|
103
|
-
if (err) {
|
|
104
|
-
fastify.log.error(err)
|
|
105
|
-
process.exit(1)
|
|
106
|
-
}
|
|
107
|
-
})
|
|
108
|
-
```
|
|
92
|
+
[`example/app.js`](example/app.js) is a complete, tested setup with `@fastify/auth`, two accounts and roles. A Casbin recipe is under [Recipes](#recipes).
|
|
109
93
|
|
|
110
|
-
|
|
94
|
+
## Query language
|
|
111
95
|
|
|
112
|
-
|
|
96
|
+
Query params are JSON strings:
|
|
113
97
|
|
|
114
|
-
|
|
98
|
+
```
|
|
99
|
+
GET /users?filters={"age":{"$gte":18},"name":"/ana/"}&options={"page":2,"limit":20,"sort":"-createdAt name"}
|
|
100
|
+
```
|
|
115
101
|
|
|
116
|
-
|
|
102
|
+
| Piece | Allowed |
|
|
103
|
+
|---|---|
|
|
104
|
+
| Comparison | `$eq $ne $gt $gte $lt $lte` with plain values |
|
|
105
|
+
| Lists | `$in $nin` (and `$all`, opt-in) with arrays of plain values |
|
|
106
|
+
| Other | `$exists` (boolean); `$size` (opt-in) |
|
|
107
|
+
| Logical | `$or $and` (and `$nor`, opt-in), non-empty arrays |
|
|
108
|
+
| Text | `"/text/"` as a value: case-insensitive *contains* on an escaped literal (`query.search: 'prefix'` makes it `^text`, which can use an index) |
|
|
109
|
+
| Sort | a string (`"-createdAt name"`) or an object (`{"name": 1}`), field names validated |
|
|
110
|
+
| Never | `$where $expr $function $accumulator $regex $text $jsonSchema $elemMatch` |
|
|
111
|
+
|
|
112
|
+
The default limits (all configurable through `query`) are depth 4, 100 keys, 1000 array items, 20 branches per `$or`/`$and`, 64 characters of search text and 5 sort fields. Anything else is a `400` with `code: "invalid_query"`.
|
|
113
|
+
|
|
114
|
+
## Options
|
|
115
|
+
|
|
116
|
+
`fastify.register(Crudify, options)`:
|
|
117
|
+
|
|
118
|
+
| Option | Default | Meaning |
|
|
119
|
+
|---|---|---|
|
|
120
|
+
| `url` | required | Base path, e.g. `/users`. May contain params: `/orders/:orderId/lines` |
|
|
121
|
+
| `Model` | required | Mongoose model (with `paginate` if `list` is enabled) |
|
|
122
|
+
| `methods` | all five | Subset of `list get create update delete` |
|
|
123
|
+
| `scope` | none (warns) | `(req, action) => filter`, or `false` for "no restriction" |
|
|
124
|
+
| `authorize` | none | `(req, action) => void`; throw or return `false` to deny |
|
|
125
|
+
| `hooks` | `{}` | `beforeCreate afterCreate beforeUpdate afterUpdate beforeDelete afterDelete afterList` |
|
|
126
|
+
| `serialize` | none | `(doc, req) => object`, applied to every document sent |
|
|
127
|
+
| `mapError` | built in | `(err, req) => ({ status, body }) \| undefined`; `false` to use the app's own error handler |
|
|
128
|
+
| `query` | see below | Sanitizer settings for `filters` / `options` |
|
|
129
|
+
| `fields` | none | `writable` (allowlist), `readonly` (denylist), `hidden` (not filterable) |
|
|
130
|
+
| `bulk` | `{ max: 100, insertMany: false }` | Bulk create limits; `false` disables arrays |
|
|
131
|
+
| `lean` | `false` | Return plain objects (faster; skips getters, virtuals and `toJSON` transforms) |
|
|
132
|
+
| `select` | none | Fixed projection for reads and updates |
|
|
133
|
+
| `sanitizer` | `true` | `false` disables it (1.x behaviour, logs a warning) or pass `{ filter, options, update, create }` to replace pieces |
|
|
134
|
+
| `JSONSchema` | none | JSON Schema of the model: validates bodies and serializes responses |
|
|
135
|
+
| `schema` | none | Fastify schema per verb: `{ list, get, create, update, delete }` |
|
|
136
|
+
| `routeOptions` | `{}` | Fastify route options for every route (`preHandler`, `config`, `bodyLimit`...) |
|
|
137
|
+
| `handlers` | none | Replace one built-in verb: `{ create: (req, reply, ctx) => ... }` |
|
|
138
|
+
| `routes` | none | `(api, ctx) => void`: add your own routes |
|
|
139
|
+
| `actions` | none | Non-CRUD operations on one document, see below |
|
|
140
|
+
| `strict` | `false` | Throw at startup if `scope` is missing or the sanitizer is disabled |
|
|
141
|
+
| `createScope` | `'strict'` | When the scope's `{ $in }` keys are checked on create: `'strict'` (before `beforeCreate`) or `'after-hooks'` (on the final document), see "Deriving a scope key" |
|
|
142
|
+
|
|
143
|
+
`query`: `operators`, `maxDepth`, `maxKeys`, `maxArray`, `maxBranches`, `search` (`'contains' | 'prefix' | false`), `maxSearchLength`, `filterable` (allowlist of root fields), `defaultLimit` (50), `maxLimit` (100), `maxSort`, `populate` (paths a client may populate).
|
|
144
|
+
|
|
145
|
+
`createdAt`, `updatedAt` (including custom names from `timestamps`), `_id`, `__v` and the root keys of your scope are always read-only for clients. Registering the plugin with invalid options throws immediately.
|
|
146
|
+
|
|
147
|
+
## Errors
|
|
148
|
+
|
|
149
|
+
Every error uses one body, the same `{ status, error, message }` shape the 404 always had, plus an optional `code`:
|
|
150
|
+
|
|
151
|
+
| Case | Status | Notes |
|
|
152
|
+
|---|---|---|
|
|
153
|
+
| Not found, out of scope, invalid id | 404 | `ID: <id> not found` |
|
|
154
|
+
| Invalid query, body or operators | 400 | `code: "invalid_query"` / `"invalid_body"` |
|
|
155
|
+
| Mongoose validation | 400 | `Fields: [a, b] are required and cannot be empty`, or `Invalid fields: [...]` with `details.fields`; custom messages stay private |
|
|
156
|
+
| Cast error | 400 | `Invalid value for "<path>"` |
|
|
157
|
+
| `authorize` / `scope` deny | 403 | `Forbidden` (or your `CrudifyError`) |
|
|
158
|
+
| Bulk over `bulk.max` | 413 | |
|
|
159
|
+
| Duplicate key (E11000) | 409 | `code: "duplicate_key"`; **never** the collection, index or value |
|
|
160
|
+
| Version conflict | 409 | `code: "version_conflict"` |
|
|
161
|
+
| Anything else | 500 | `Internal error`; the real error is logged |
|
|
162
|
+
|
|
163
|
+
To show a message to the client from your own code, throw a `CrudifyError`:
|
|
117
164
|
|
|
118
165
|
```js
|
|
119
|
-
|
|
166
|
+
import { CrudifyError } from '@aaroncadillac/crudify-mongo'
|
|
167
|
+
throw new CrudifyError(409, 'This order is already paid', { code: 'order_paid' })
|
|
168
|
+
```
|
|
120
169
|
|
|
121
|
-
|
|
122
|
-
import paginate from 'mongoose-paginate-v2'; // Required for pagination
|
|
123
|
-
import extendMongoose from 'mongoose-schema-jsonschema'; // Required for body and response schema auto generation
|
|
170
|
+
`toErrorResponse(err)` is exported if you want the same mapping in your own `setErrorHandler`.
|
|
124
171
|
|
|
125
|
-
|
|
172
|
+
## Scope and authorize
|
|
126
173
|
|
|
127
|
-
|
|
174
|
+
`scope` is a filter ANDed with every read and write, so a document outside it is simply "not found":
|
|
128
175
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
required: true,
|
|
137
|
-
},
|
|
138
|
-
email: {
|
|
139
|
-
type: String,
|
|
140
|
-
required: true,
|
|
141
|
-
},
|
|
142
|
-
}, {
|
|
143
|
-
timestamps: true,
|
|
144
|
-
});
|
|
176
|
+
```js
|
|
177
|
+
app.register(Crudify, {
|
|
178
|
+
url: '/orders',
|
|
179
|
+
Model: Order,
|
|
180
|
+
scope: (req) => ({ accountId: req.user.accountId })
|
|
181
|
+
})
|
|
182
|
+
```
|
|
145
183
|
|
|
146
|
-
|
|
184
|
+
- **list / get / update / delete:** the scope is combined with the client filter and the id. If a key collides (the client also filters on `accountId`), both conditions are kept with `$and`, so the client can never override the scope.
|
|
185
|
+
- **create:** root keys with an equality value are *forced* into the new document (the client value is ignored). With `{ $in: [...] }` the client must send a value from the list (`400` if missing, `403` if not allowed).
|
|
186
|
+
- **update:** root keys of the scope can't be rewritten, so a document can never be moved out of its scope. If your scope uses `$or`, list the involved fields in `fields.readonly`.
|
|
187
|
+
- `scope` must return a plain object. Anything else is a `403` (fail closed). Return `{}` to say "no restriction" explicitly, or pass `scope: false`.
|
|
188
|
+
- `authorize` runs first, before any query. Use it for role checks that don't depend on the document; use `scope` for anything about *which* documents (it is atomic: no read-then-check race).
|
|
147
189
|
|
|
148
|
-
|
|
149
|
-
const UserModel = mongoose.model('Users', userSchema);
|
|
190
|
+
### Deriving a scope key in `beforeCreate` (`createScope`)
|
|
150
191
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
155
|
-
```
|
|
192
|
+
Sometimes the client does not send the scope key because it is *derived*: an order carries `tableIds`, and its `restaurantId` comes from those tables. By default (`createScope: 'strict'`, the behaviour of 3.0.0) Crudify demands the `{ $in }` key **before** `beforeCreate` runs, so such a resource answers `400 restaurantId is required`.
|
|
193
|
+
|
|
194
|
+
With `createScope: 'after-hooks'` the create pipeline becomes:
|
|
156
195
|
|
|
157
|
-
|
|
196
|
+
1. sanitize the client document (scope keys the client sent are kept, forced equality keys are dropped);
|
|
197
|
+
2. run `beforeCreate(data, req)`, which can derive or stamp the key;
|
|
198
|
+
3. **validate the final document**: every `{ $in }` key must exist (`400 <field> is required`) and be in the list (`403 Forbidden`);
|
|
199
|
+
4. impose the equality keys last, so the hook cannot overwrite them.
|
|
158
200
|
|
|
159
201
|
```js
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
202
|
+
app.register(Crudify, {
|
|
203
|
+
url: '/orders',
|
|
204
|
+
Model: Order,
|
|
205
|
+
createScope: 'after-hooks',
|
|
206
|
+
scope: async (req) => ({
|
|
207
|
+
accountId: req.user.accountId,
|
|
208
|
+
restaurantId: { $in: await allowedRestaurantIds(req.user) } // e.g. from your permission system
|
|
209
|
+
}),
|
|
210
|
+
hooks: {
|
|
211
|
+
// `data` has no accountId yet: it is forced after this hook
|
|
212
|
+
beforeCreate: async (data) => {
|
|
213
|
+
const tables = await Table.find({ _id: { $in: data.tableIds ?? [] } }).lean()
|
|
214
|
+
const restaurants = new Set(tables.map(t => String(t.restaurantId)))
|
|
215
|
+
if (tables.length !== data.tableIds?.length || restaurants.size !== 1) {
|
|
216
|
+
throw new CrudifyError(400, 'tableIds must belong to a single restaurant')
|
|
217
|
+
}
|
|
218
|
+
return { ...data, restaurantId: [...restaurants][0] }
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
})
|
|
171
222
|
```
|
|
172
223
|
|
|
173
|
-
|
|
224
|
+
What stays guaranteed: a hook can derive the key but **never move a document out of its scope**, because Crudify checks the result and forces the equality keys itself. Bulk requests are validated entirely before anything is written. `list`, `get`, `update` and `delete` are not affected.
|
|
174
225
|
|
|
175
|
-
|
|
226
|
+
What you take on by choosing it:
|
|
176
227
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
228
|
+
- `beforeCreate` receives the document **without the forced keys** (`accountId` above is not there yet). Don't rely on them inside the hook.
|
|
229
|
+
- The hook must derive the key from trusted data (look it up in your database; never copy it from another client-supplied field without checking).
|
|
230
|
+
- A hook that derives nothing is a `400`, and one that derives a value outside the caller's list is a `403`.
|
|
180
231
|
|
|
181
|
-
|
|
232
|
+
Not recommended: setting `scope: {}` for the action and validating the tenant yourself inside `beforeCreate`. It works, but then nothing is checked by Crudify and a forgotten check is a cross-tenant leak; that risk is entirely yours.
|
|
182
233
|
|
|
183
|
-
|
|
234
|
+
## Hooks and request order
|
|
184
235
|
|
|
185
236
|
```js
|
|
186
|
-
|
|
237
|
+
app.register(Crudify, {
|
|
238
|
+
url: '/orders',
|
|
239
|
+
Model: Order,
|
|
240
|
+
hooks: {
|
|
241
|
+
beforeCreate: async (data, req) => ({ ...data, createdBy: req.user.id }), // return the (new) data
|
|
242
|
+
afterCreate: async (docOrDocs, req) => {},
|
|
243
|
+
beforeUpdate: async (changes, req) => changes,
|
|
244
|
+
afterUpdate: async (doc, req) => {},
|
|
245
|
+
beforeDelete: async (doc, req) => { if (doc.paid) throw new CrudifyError(409, 'Paid orders cannot be deleted') },
|
|
246
|
+
afterDelete: async (doc, req) => {},
|
|
247
|
+
afterList: async (result, req) => result // the paginate result; return it or a new one
|
|
248
|
+
}
|
|
249
|
+
})
|
|
250
|
+
```
|
|
187
251
|
|
|
188
|
-
|
|
189
|
-
import paginate from 'mongoose-paginate-v2'; // Required for pagination
|
|
252
|
+
Order for every request: Fastify hooks and schema validation, `routeOptions.preHandler`, cheap structural checks (id format, body shape, bulk size), `authorize`, `scope`, sanitizing, `before*`, the Mongo operation, `after*`, `serialize`. Any hook can stop the request by throwing a `CrudifyError`. Declaring `beforeDelete` costs one extra query (it needs the document); without it, delete is a single `findOneAndDelete`. An error thrown from an `after*` hook happens after the write: use `onResponse` for non-critical side effects.
|
|
190
253
|
|
|
191
|
-
|
|
254
|
+
`req.crudify` is `{ action, filter, doc }` for the current request, readable from your own Fastify hooks.
|
|
192
255
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
256
|
+
## Custom routes, actions and overrides
|
|
257
|
+
|
|
258
|
+
Everything below shares the same `authorize`, `scope` and error handling as the built-in verbs, and receives a context `ctx`:
|
|
259
|
+
|
|
260
|
+
```js
|
|
261
|
+
app.register(Crudify, {
|
|
262
|
+
url: '/orders',
|
|
263
|
+
Model: Order,
|
|
264
|
+
scope: (req) => ({ accountId: req.user.accountId }),
|
|
265
|
+
authorize: (req, action) => { if (action === 'close' && req.user.role !== 'admin') throw new CrudifyError(403, 'Admins only') },
|
|
266
|
+
|
|
267
|
+
// 1. an action on one document: POST /orders/:id/close (the document arrives loaded, in scope)
|
|
268
|
+
actions: {
|
|
269
|
+
close: {
|
|
270
|
+
handler: async (req, reply, ctx) => {
|
|
271
|
+
const order = req.crudify.doc
|
|
272
|
+
order.status = 'closed'
|
|
273
|
+
await order.save()
|
|
274
|
+
return ctx.serialize(order, req)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
201
277
|
},
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
278
|
+
|
|
279
|
+
// 2. free-form routes
|
|
280
|
+
routes: (api) => {
|
|
281
|
+
api.get('/stats', { action: 'list' }, async (req, reply, ctx) => ({
|
|
282
|
+
open: await ctx.Model.countDocuments(await ctx.where(req, 'list', { status: 'open' }))
|
|
283
|
+
}))
|
|
284
|
+
api.get('/:id/light', { load: true }, async (req) => ({ id: req.crudify.doc._id }))
|
|
205
285
|
},
|
|
206
|
-
}, {
|
|
207
|
-
timestamps: true,
|
|
208
|
-
});
|
|
209
286
|
|
|
210
|
-
|
|
287
|
+
// 3. replace one built-in verb, keeping authorize + scope
|
|
288
|
+
handlers: {
|
|
289
|
+
list: async (req, reply, ctx) => ({ total: (await ctx.list(req)).totalDocs })
|
|
290
|
+
}
|
|
291
|
+
})
|
|
292
|
+
```
|
|
211
293
|
|
|
212
|
-
|
|
294
|
+
`actions.<name>` options: `handler`, `method` (`POST`), `path` (`/:id/<name>`), `load` (`true`), `action` (the name given to `authorize`/`scope`, default the key), plus any Fastify route option such as `schema`.
|
|
213
295
|
|
|
214
|
-
|
|
215
|
-
UserModel
|
|
216
|
-
}
|
|
217
|
-
```
|
|
296
|
+
`api.get|post|put|patch|delete(path, [options], handler)`; options are Fastify route options plus `action` (what `authorize`/`scope` see; defaults to `get` for GET, `delete` for DELETE, `update` otherwise) and `load` (load the document into `req.crudify.doc`).
|
|
218
297
|
|
|
219
|
-
|
|
298
|
+
`ctx` has `Model`, `url`, `scope(req, action)`, `where(req, action, base)` (a filter with the scope applied), `load(req, action, id)` (a real Mongoose document, or 404), `list(req)` (sanitized + scoped paginate), `sanitize.{filter, options, update, create, query}`, `serialize`, `notFound` and `checkId`.
|
|
299
|
+
|
|
300
|
+
> `additionalRoutes` (deprecated in 2.x) was removed: passing it throws at registration. Use `routes` or `actions`.
|
|
301
|
+
|
|
302
|
+
## Recipes
|
|
303
|
+
|
|
304
|
+
**A tenant resource in five lines.** Multi-tenancy is your domain, so keep the factory in your app:
|
|
220
305
|
|
|
221
306
|
```js
|
|
222
|
-
|
|
307
|
+
const owned = (url, Model, extra = {}) => ({ url, Model, scope: (req) => ({ accountId: req.user.accountId }), ...extra })
|
|
308
|
+
app.register(Crudify, owned('/tables', Table))
|
|
309
|
+
app.register(Crudify, owned('/menus', Menu, { methods: ['list', 'get'] }))
|
|
310
|
+
```
|
|
223
311
|
|
|
224
|
-
|
|
312
|
+
**Sub-resources.** The parent id is just part of the URL and the scope:
|
|
225
313
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
314
|
+
```js
|
|
315
|
+
app.register(Crudify, {
|
|
316
|
+
url: '/orders/:orderId/lines',
|
|
317
|
+
Model: Line,
|
|
318
|
+
scope: async (req) => {
|
|
319
|
+
if (!(await Order.exists({ _id: req.params.orderId, accountId: req.user.accountId }))) throw new CrudifyError(404, 'Not found')
|
|
320
|
+
return { orderId: req.params.orderId } // forced on create, ANDed on the rest
|
|
233
321
|
}
|
|
234
|
-
}
|
|
322
|
+
})
|
|
323
|
+
```
|
|
235
324
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
325
|
+
**Casbin** (or any policy engine), with no extra dependency in Crudify:
|
|
326
|
+
|
|
327
|
+
```js
|
|
328
|
+
const ACTION = { list: 'read', get: 'read', create: 'create', update: 'update', delete: 'delete' }
|
|
329
|
+
|
|
330
|
+
app.register(Crudify, {
|
|
331
|
+
url: '/orders',
|
|
332
|
+
Model: Order,
|
|
333
|
+
authorize: async (req, action) => {
|
|
334
|
+
const allowed = await enforcer.enforce(req.user.id, req.user.accountId, 'orders', ACTION[action] ?? action)
|
|
335
|
+
if (!allowed) throw new CrudifyError(403, 'Forbidden')
|
|
336
|
+
},
|
|
337
|
+
scope: (req) => ({ accountId: req.user.accountId }) // the policy domain doubles as the data scope
|
|
338
|
+
})
|
|
239
339
|
```
|
|
240
340
|
|
|
241
|
-
|
|
341
|
+
**Hide fields from responses:**
|
|
242
342
|
|
|
243
343
|
```js
|
|
244
|
-
|
|
344
|
+
serialize: (doc) => { const { passwordHash, ...rest } = doc.toObject({ flattenObjectIds: true }); return rest }
|
|
345
|
+
```
|
|
245
346
|
|
|
246
|
-
|
|
247
|
-
import Crudify from 'crudify-mongo';
|
|
248
|
-
import { dbConnection } from './db-connection';
|
|
249
|
-
import { UserModel } from './models/user';
|
|
347
|
+
## The sanitizer on its own
|
|
250
348
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
349
|
+
The sanitizer is a separate module you can use in your own handlers:
|
|
350
|
+
|
|
351
|
+
```js
|
|
352
|
+
import { sanitizeFilter, sanitizeOptions, sanitizeUpdate, createSanitizer, parseJson } from '@aaroncadillac/crudify-mongo/sanitizer'
|
|
254
353
|
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
Model: UserModel
|
|
259
|
-
})
|
|
260
|
-
}
|
|
354
|
+
const filter = sanitizeFilter(parseJson(req.query.filters, 'filters'), { maxDepth: 2 })
|
|
355
|
+
const { filter: f, options, update, create } = createSanitizer({ maxLimit: 25, writable: ['name'] }) // compile once, reuse
|
|
356
|
+
```
|
|
261
357
|
|
|
262
|
-
|
|
263
|
-
fastify.register(routes)
|
|
358
|
+
Pass `sanitizer: { filter: myFn }` to Crudify to replace a piece, or `sanitizer: false` to turn it off (not recommended).
|
|
264
359
|
|
|
360
|
+
## JSON Schema and Swagger
|
|
265
361
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
}
|
|
271
|
-
})
|
|
362
|
+
Give Crudify the JSON Schema of your model (for example from [`mongoose-schema-jsonschema`](https://github.com/DScheglov/mongoose-schema-jsonschema)) and it validates bodies and serializes responses with it:
|
|
363
|
+
|
|
364
|
+
```js
|
|
365
|
+
app.register(Crudify, { url: '/users', Model: User, JSONSchema: userJsonSchema, scope: false })
|
|
272
366
|
```
|
|
273
367
|
|
|
274
|
-
|
|
368
|
+
The list response is typed as the paginate result, `POST` accepts one object or an array, read-only fields are removed from the body schema (so a key forced by your scope is never "required" from the client), and `x-ref` keys are stripped. Responses drop `required`, so `select` and `serialize` can hide fields freely. Use `schema: { list: {...}, ... }` to override any verb, and `@fastify/swagger` picks everything up.
|
|
369
|
+
|
|
370
|
+
## Performance
|
|
275
371
|
|
|
276
|
-
|
|
372
|
+
Everything that can be computed is computed once at registration: allowlist `Set`s, the sanitizer, route schemas, error handler and the context. Per request there is a single `JSON.parse` for each query param, one pass of the sanitizer that builds its output directly, one query (paginate does two) and no `try/catch` per route. `node bench/run.js` (no extra dependencies) measures it; on an Apple-silicon laptop with a stubbed model, a typical filter sanitizes in about 0.8 µs and Crudify adds roughly 0.5-0.8 µs per request over a bare Fastify route. `lean: true` skips Mongoose document hydration on reads when you don't need getters, virtuals or `toJSON` transforms.
|
|
277
373
|
|
|
278
|
-
|
|
374
|
+
## Acknowledgements
|
|
375
|
+
|
|
376
|
+
Inspired by [fastify-mongoose-crud](https://github.com/paranoiasystem/fastify-autocrud).
|
|
279
377
|
|
|
280
378
|
## License
|
|
281
379
|
|
|
282
380
|
[Mozilla Public License 2.0](/LICENSE)
|
|
283
|
-
|