prisma-generator-express 1.20.0 → 1.21.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/README.md +657 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -16,6 +16,30 @@ Running `npx prisma generate` produces:
|
|
|
16
16
|
- Client-side query parameter encoder
|
|
17
17
|
- Guard/variant shape enforcement via prisma-guard integration
|
|
18
18
|
|
|
19
|
+
## Table of contents
|
|
20
|
+
|
|
21
|
+
- [Compatibility](#compatibility)
|
|
22
|
+
- [Installation](#installation)
|
|
23
|
+
- [Setup](#setup)
|
|
24
|
+
- [Usage](#usage)
|
|
25
|
+
- [Selective routes with middleware](#selective-routes-with-middleware)
|
|
26
|
+
- [Guard shapes (prisma-guard integration)](#guard-shapes-prisma-guard-integration)
|
|
27
|
+
- [Request body format](#request-body-format)
|
|
28
|
+
- [Query encoding (client side)](#query-encoding-client-side)
|
|
29
|
+
- [Response shaping: select, include, omit](#response-shaping-select-include-omit)
|
|
30
|
+
- [BigInt and Decimal handling](#bigint-and-decimal-handling)
|
|
31
|
+
- [Pagination](#pagination)
|
|
32
|
+
- [Error handling](#error-handling)
|
|
33
|
+
- [Security](#security)
|
|
34
|
+
- [Documentation endpoints](#documentation-endpoints)
|
|
35
|
+
- [prisma-sql integration](#prisma-sql-integration)
|
|
36
|
+
- [Query parameter parsing](#query-parameter-parsing)
|
|
37
|
+
- [Router schema](#router-schema)
|
|
38
|
+
- [Skipping models](#skipping-models)
|
|
39
|
+
- [Configuration](#configuration)
|
|
40
|
+
- [Environment variables](#environment-variables)
|
|
41
|
+
- [License](#license)
|
|
42
|
+
|
|
19
43
|
## Compatibility
|
|
20
44
|
|
|
21
45
|
### Prisma version
|
|
@@ -54,7 +78,7 @@ npm install @prisma/client express
|
|
|
54
78
|
Optional peer dependencies:
|
|
55
79
|
```bash
|
|
56
80
|
npm install prisma-sql # SQL optimization
|
|
57
|
-
npm install prisma-guard
|
|
81
|
+
npm install prisma-guard zod # Guard shape enforcement
|
|
58
82
|
npm install prisma-query-builder-ui # Visual query playground
|
|
59
83
|
```
|
|
60
84
|
|
|
@@ -120,30 +144,171 @@ app.use('/', UserRouter(userConfig))
|
|
|
120
144
|
|
|
121
145
|
Only operations listed in the config (or all when `enableAll: true`) are registered. Operations not listed produce no routes.
|
|
122
146
|
|
|
123
|
-
## Guard shapes (
|
|
147
|
+
## Guard shapes (prisma-guard integration)
|
|
148
|
+
|
|
149
|
+
prisma-generator-express integrates with [prisma-guard](https://github.com/multipliedtwice/prisma-guard) to enforce input validation, query shape restrictions, and tenant isolation on generated routes. When a `shape` is configured on an operation, the handler calls `prisma.model.guard(shape, caller).method(args)` instead of `prisma.model.method(args)`.
|
|
124
150
|
|
|
125
|
-
Guard
|
|
151
|
+
### Guard setup
|
|
126
152
|
|
|
127
|
-
|
|
153
|
+
Install prisma-guard and add its generator to your schema:
|
|
128
154
|
```bash
|
|
129
|
-
npm install prisma-guard
|
|
155
|
+
npm install prisma-guard zod
|
|
156
|
+
```
|
|
157
|
+
```prisma
|
|
158
|
+
generator client {
|
|
159
|
+
provider = "prisma-client-js"
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
generator guard {
|
|
163
|
+
provider = "prisma-guard"
|
|
164
|
+
output = "generated/guard"
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
generator express {
|
|
168
|
+
provider = "prisma-generator-express"
|
|
169
|
+
}
|
|
130
170
|
```
|
|
131
171
|
|
|
132
|
-
|
|
172
|
+
Run `npx prisma generate` to emit both the express routes and the guard artifacts.
|
|
173
|
+
|
|
174
|
+
Extend PrismaClient with the guard extension and attach it to requests:
|
|
133
175
|
```ts
|
|
176
|
+
import express from 'express'
|
|
134
177
|
import { PrismaClient } from '@prisma/client'
|
|
135
|
-
import {
|
|
178
|
+
import { guard } from './generated/guard/client'
|
|
179
|
+
import { UserRouter } from './generated/User/UserRouter'
|
|
180
|
+
|
|
181
|
+
const prisma = new PrismaClient().$extends(
|
|
182
|
+
guard.extension(() => ({
|
|
183
|
+
// scope context, caller, or any other values
|
|
184
|
+
}))
|
|
185
|
+
)
|
|
136
186
|
|
|
137
|
-
const
|
|
187
|
+
const app = express()
|
|
188
|
+
|
|
189
|
+
app.use((req, res, next) => {
|
|
190
|
+
req.prisma = prisma
|
|
191
|
+
next()
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
app.use('/', UserRouter({
|
|
195
|
+
findMany: {
|
|
196
|
+
shape: {
|
|
197
|
+
where: { name: { contains: true } },
|
|
198
|
+
take: { max: 50, default: 20 },
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
}))
|
|
202
|
+
|
|
203
|
+
app.listen(3000)
|
|
138
204
|
```
|
|
139
205
|
|
|
140
|
-
|
|
206
|
+
If prisma-guard is not installed or the client is not extended with the guard extension, requests to guarded routes return 500 with the message: `Guard shapes require prisma-guard extension on PrismaClient. Install: npm install prisma-guard, then extend your client with guardExtension().`
|
|
207
|
+
|
|
208
|
+
### How guard integration works
|
|
209
|
+
|
|
210
|
+
Each operation config accepts an optional `shape` property. When present, the generated handler:
|
|
211
|
+
|
|
212
|
+
1. Stores the shape on `res.locals.guardShape` via middleware
|
|
213
|
+
2. Resolves the caller from `config.guard.resolveVariant(req)`, then from the configured header (default `x-api-variant`), falling back to `undefined`
|
|
214
|
+
3. Calls `prisma.model.guard(shape, caller).method(args)` instead of `prisma.model.method(args)`
|
|
215
|
+
|
|
216
|
+
When `shape` is absent, the handler calls Prisma directly with no guard enforcement.
|
|
217
|
+
|
|
218
|
+
### Single shape per operation
|
|
219
|
+
|
|
220
|
+
A single shape object restricts what the client can do on that operation. No caller routing is needed.
|
|
221
|
+
```ts
|
|
222
|
+
const userConfig = {
|
|
223
|
+
findMany: {
|
|
224
|
+
shape: {
|
|
225
|
+
where: { email: { contains: true }, role: { equals: true } },
|
|
226
|
+
orderBy: { createdAt: true },
|
|
227
|
+
take: { max: 100, default: 25 },
|
|
228
|
+
skip: true,
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
create: {
|
|
232
|
+
shape: {
|
|
233
|
+
data: { email: true, name: true, role: 'user' },
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
update: {
|
|
237
|
+
shape: {
|
|
238
|
+
data: { name: true },
|
|
239
|
+
where: { id: { equals: true } },
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
delete: {
|
|
243
|
+
shape: {
|
|
244
|
+
where: { id: { equals: true } },
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
app.use('/', UserRouter(userConfig))
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
In this example:
|
|
253
|
+
|
|
254
|
+
- `findMany` allows filtering by `email` (contains) and `role` (equals), sorting by `createdAt`, pagination via `take`/`skip`. All other where fields, orderBy fields, and include/select are rejected.
|
|
255
|
+
- `create` accepts `email` and `name` from the client. `role` is forced to `'user'` regardless of what the client sends.
|
|
256
|
+
- `update` only allows changing `name`, and requires a unique `id` in `where`.
|
|
257
|
+
- `delete` requires a unique `id` in `where`.
|
|
258
|
+
|
|
259
|
+
### Shape value types in data
|
|
260
|
+
|
|
261
|
+
Each field in a `data` shape accepts one of four value types:
|
|
262
|
+
```ts
|
|
263
|
+
import { force } from 'prisma-guard'
|
|
264
|
+
|
|
265
|
+
const config = {
|
|
266
|
+
create: {
|
|
267
|
+
shape: {
|
|
268
|
+
data: {
|
|
269
|
+
email: true, // client-controlled, @zod chains apply
|
|
270
|
+
name: true, // client-controlled
|
|
271
|
+
role: 'member', // forced to 'member', client cannot override
|
|
272
|
+
isActive: force(true), // forced to boolean true (force() needed to distinguish from client-controlled)
|
|
273
|
+
bio: (base) => base.max(500), // client-controlled with inline validation override
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
}
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
- `true` — client provides the value; `@zod` schema directives from the Prisma schema apply
|
|
281
|
+
- literal value — server forces this value; client input is ignored
|
|
282
|
+
- `force(value)` — same as literal, but required when the forced value is literally `true` (since bare `true` means client-controlled)
|
|
283
|
+
- `(base) => schema` — client provides the value; the function receives the base Zod type and returns a refined schema, bypassing `@zod` chains
|
|
284
|
+
|
|
285
|
+
### Named shapes (variant-based routing)
|
|
286
|
+
|
|
287
|
+
Different API consumers often need different shapes for the same operation. Named shapes use a caller value to route to the correct shape.
|
|
141
288
|
```ts
|
|
142
289
|
const userConfig = {
|
|
143
290
|
findMany: {
|
|
144
291
|
shape: {
|
|
145
|
-
admin: {
|
|
146
|
-
|
|
292
|
+
admin: {
|
|
293
|
+
where: { email: { contains: true }, role: { equals: true }, isActive: { equals: true } },
|
|
294
|
+
include: { posts: true, profile: true },
|
|
295
|
+
take: { max: 200 },
|
|
296
|
+
},
|
|
297
|
+
public: {
|
|
298
|
+
where: { name: { contains: true } },
|
|
299
|
+
select: { id: true, name: true },
|
|
300
|
+
take: { max: 20, default: 10 },
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
create: {
|
|
305
|
+
shape: {
|
|
306
|
+
admin: {
|
|
307
|
+
data: { email: true, name: true, role: true, isActive: true },
|
|
308
|
+
},
|
|
309
|
+
editor: {
|
|
310
|
+
data: { email: true, name: true, role: 'member' },
|
|
311
|
+
},
|
|
147
312
|
},
|
|
148
313
|
},
|
|
149
314
|
guard: {
|
|
@@ -154,7 +319,487 @@ const userConfig = {
|
|
|
154
319
|
app.use('/', UserRouter(userConfig))
|
|
155
320
|
```
|
|
156
321
|
|
|
157
|
-
|
|
322
|
+
The client sends the variant in the configured header:
|
|
323
|
+
```ts
|
|
324
|
+
// Admin frontend
|
|
325
|
+
fetch('/user', {
|
|
326
|
+
headers: { 'x-api-variant': 'admin' },
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
// Public frontend
|
|
330
|
+
fetch('/user', {
|
|
331
|
+
headers: { 'x-api-variant': 'public' },
|
|
332
|
+
})
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
If the caller is missing or doesn't match any key, the request is rejected with 400 (`CallerError`).
|
|
336
|
+
|
|
337
|
+
### Custom caller resolution
|
|
338
|
+
|
|
339
|
+
Use `resolveVariant` for caller logic beyond a simple header:
|
|
340
|
+
```ts
|
|
341
|
+
const userConfig = {
|
|
342
|
+
findMany: {
|
|
343
|
+
shape: {
|
|
344
|
+
admin: { /* ... */ },
|
|
345
|
+
public: { /* ... */ },
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
guard: {
|
|
349
|
+
resolveVariant: (req) => {
|
|
350
|
+
if (req.user?.role === 'admin') return 'admin'
|
|
351
|
+
return 'public'
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
}
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
`resolveVariant` takes priority over the header. If both are configured, the header is checked only when `resolveVariant` returns `undefined`.
|
|
358
|
+
|
|
359
|
+
### Parameterized caller patterns
|
|
360
|
+
|
|
361
|
+
Caller keys support parameterized path patterns:
|
|
362
|
+
```ts
|
|
363
|
+
const projectConfig = {
|
|
364
|
+
update: {
|
|
365
|
+
shape: {
|
|
366
|
+
'/admin/projects/:id': {
|
|
367
|
+
data: { title: true, status: true, priority: true },
|
|
368
|
+
where: { id: { equals: true } },
|
|
369
|
+
},
|
|
370
|
+
'/editor/projects/:id': {
|
|
371
|
+
data: { title: true },
|
|
372
|
+
where: { id: { equals: true } },
|
|
373
|
+
},
|
|
374
|
+
},
|
|
375
|
+
},
|
|
376
|
+
guard: {
|
|
377
|
+
variantHeader: 'x-caller',
|
|
378
|
+
},
|
|
379
|
+
}
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
The client sends the full path:
|
|
383
|
+
```ts
|
|
384
|
+
fetch('/project', {
|
|
385
|
+
method: 'PUT',
|
|
386
|
+
headers: {
|
|
387
|
+
'x-caller': '/admin/projects/abc123',
|
|
388
|
+
'Content-Type': 'application/json',
|
|
389
|
+
},
|
|
390
|
+
body: JSON.stringify({
|
|
391
|
+
where: { id: { equals: 'abc123' } },
|
|
392
|
+
data: { title: 'Updated', status: 'active' },
|
|
393
|
+
}),
|
|
394
|
+
})
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
Exact matches are checked first. Parameters (`:id`) are routing-only and are not extracted.
|
|
398
|
+
|
|
399
|
+
### Forced where conditions
|
|
400
|
+
|
|
401
|
+
Literal values in `where` shapes are forced server-side and cannot be overridden by the client:
|
|
402
|
+
```ts
|
|
403
|
+
import { force } from 'prisma-guard'
|
|
404
|
+
|
|
405
|
+
const projectConfig = {
|
|
406
|
+
findMany: {
|
|
407
|
+
shape: {
|
|
408
|
+
where: {
|
|
409
|
+
status: { equals: 'published' }, // always filter to published
|
|
410
|
+
isDeleted: { equals: false }, // always exclude deleted
|
|
411
|
+
isActive: { equals: force(true) }, // force() needed for boolean true
|
|
412
|
+
title: { contains: true }, // client-controlled
|
|
413
|
+
},
|
|
414
|
+
take: { max: 50 },
|
|
415
|
+
},
|
|
416
|
+
},
|
|
417
|
+
}
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
A request with `{ where: { title: { contains: 'demo' } } }` produces:
|
|
421
|
+
```
|
|
422
|
+
WHERE status = 'published'
|
|
423
|
+
AND isDeleted = false
|
|
424
|
+
AND isActive = true
|
|
425
|
+
AND title LIKE '%demo%'
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
The client cannot bypass the forced conditions.
|
|
429
|
+
|
|
430
|
+
### Logical combinators (AND, OR, NOT)
|
|
431
|
+
|
|
432
|
+
Where shapes support `AND`, `OR`, and `NOT`. The combinator value defines which fields are allowed inside it:
|
|
433
|
+
```ts
|
|
434
|
+
const config = {
|
|
435
|
+
findMany: {
|
|
436
|
+
shape: {
|
|
437
|
+
where: {
|
|
438
|
+
OR: {
|
|
439
|
+
title: { contains: true },
|
|
440
|
+
description: { contains: true },
|
|
441
|
+
},
|
|
442
|
+
status: { equals: 'published' }, // forced, always applied
|
|
443
|
+
},
|
|
444
|
+
take: { max: 50 },
|
|
445
|
+
},
|
|
446
|
+
},
|
|
447
|
+
}
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
Client sends:
|
|
451
|
+
```json
|
|
452
|
+
{
|
|
453
|
+
"where": {
|
|
454
|
+
"OR": [
|
|
455
|
+
{ "title": { "contains": "demo" } },
|
|
456
|
+
{ "description": { "contains": "demo" } }
|
|
457
|
+
]
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
The forced `status = 'published'` is always merged as an AND condition. Forced values inside combinators are lifted to the top-level query, regardless of the combinator type.
|
|
463
|
+
|
|
464
|
+
### Relation filters in where
|
|
465
|
+
|
|
466
|
+
Where shapes support relation-level filters. To-many relations use `some`, `every`, `none`. To-one relations use `is`, `isNot`.
|
|
467
|
+
```ts
|
|
468
|
+
const userConfig = {
|
|
469
|
+
findMany: {
|
|
470
|
+
shape: {
|
|
471
|
+
where: {
|
|
472
|
+
posts: {
|
|
473
|
+
some: {
|
|
474
|
+
title: { contains: true },
|
|
475
|
+
published: { equals: true }, // forced inside the relation
|
|
476
|
+
},
|
|
477
|
+
},
|
|
478
|
+
},
|
|
479
|
+
take: { max: 50 },
|
|
480
|
+
},
|
|
481
|
+
},
|
|
482
|
+
}
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
The client can filter by `title` inside the relation, but `published = true` is always enforced.
|
|
486
|
+
|
|
487
|
+
### Select, include, and omit in shapes
|
|
488
|
+
|
|
489
|
+
Shapes can restrict which response fields and relations the client may request:
|
|
490
|
+
```ts
|
|
491
|
+
const userConfig = {
|
|
492
|
+
findMany: {
|
|
493
|
+
shape: {
|
|
494
|
+
where: { role: { equals: true } },
|
|
495
|
+
select: {
|
|
496
|
+
id: true,
|
|
497
|
+
email: true,
|
|
498
|
+
name: true,
|
|
499
|
+
posts: {
|
|
500
|
+
select: { id: true, title: true },
|
|
501
|
+
},
|
|
502
|
+
_count: {
|
|
503
|
+
select: { posts: true },
|
|
504
|
+
},
|
|
505
|
+
},
|
|
506
|
+
take: { max: 50 },
|
|
507
|
+
},
|
|
508
|
+
},
|
|
509
|
+
}
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
The client can only select from the whitelisted fields and relations. Attempting to select unlisted fields (e.g. `passwordHash`) is rejected.
|
|
513
|
+
|
|
514
|
+
`select` and `include` are mutually exclusive at the same level in both the shape and the client request.
|
|
515
|
+
|
|
516
|
+
### Nested include with forced where and pagination
|
|
517
|
+
|
|
518
|
+
Nested includes on to-many relations support `where`, `orderBy`, `cursor`, `take`, and `skip`:
|
|
519
|
+
```ts
|
|
520
|
+
import { force } from 'prisma-guard'
|
|
521
|
+
|
|
522
|
+
const userConfig = {
|
|
523
|
+
findMany: {
|
|
524
|
+
shape: {
|
|
525
|
+
include: {
|
|
526
|
+
posts: {
|
|
527
|
+
where: { isDeleted: { equals: false } }, // forced: never return deleted posts
|
|
528
|
+
orderBy: { createdAt: true },
|
|
529
|
+
take: { max: 20, default: 10 },
|
|
530
|
+
skip: true,
|
|
531
|
+
},
|
|
532
|
+
profile: true, // simple include, no constraints
|
|
533
|
+
_count: {
|
|
534
|
+
select: {
|
|
535
|
+
posts: {
|
|
536
|
+
where: { isDeleted: { equals: false } }, // count only non-deleted
|
|
537
|
+
},
|
|
538
|
+
},
|
|
539
|
+
},
|
|
540
|
+
},
|
|
541
|
+
take: { max: 50 },
|
|
542
|
+
},
|
|
543
|
+
},
|
|
544
|
+
}
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
### Mutation return projection
|
|
548
|
+
|
|
549
|
+
Write operations that return records (`create`, `update`, `upsert`, `delete`, `createManyAndReturn`, `updateManyAndReturn`) support `select` and `include` in the shape:
|
|
550
|
+
```ts
|
|
551
|
+
const userConfig = {
|
|
552
|
+
create: {
|
|
553
|
+
shape: {
|
|
554
|
+
data: { email: true, name: true },
|
|
555
|
+
include: {
|
|
556
|
+
profile: true,
|
|
557
|
+
},
|
|
558
|
+
},
|
|
559
|
+
},
|
|
560
|
+
update: {
|
|
561
|
+
shape: {
|
|
562
|
+
data: { name: true },
|
|
563
|
+
where: { id: { equals: true } },
|
|
564
|
+
select: {
|
|
565
|
+
id: true,
|
|
566
|
+
name: true,
|
|
567
|
+
updatedAt: true,
|
|
568
|
+
},
|
|
569
|
+
},
|
|
570
|
+
},
|
|
571
|
+
}
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
The client can include `include` or `select` in the request body. If the shape does not define projection, the client cannot request one. Batch methods (`createMany`, `updateMany`, `deleteMany`) do not support projection.
|
|
575
|
+
|
|
576
|
+
### Upsert
|
|
577
|
+
|
|
578
|
+
Upsert uses `create` and `update` shape keys instead of `data`:
|
|
579
|
+
```ts
|
|
580
|
+
import { force } from 'prisma-guard'
|
|
581
|
+
|
|
582
|
+
const projectConfig = {
|
|
583
|
+
upsert: {
|
|
584
|
+
shape: {
|
|
585
|
+
where: { id: { equals: true } },
|
|
586
|
+
create: {
|
|
587
|
+
title: true,
|
|
588
|
+
status: 'draft',
|
|
589
|
+
isActive: force(true),
|
|
590
|
+
},
|
|
591
|
+
update: {
|
|
592
|
+
title: true,
|
|
593
|
+
},
|
|
594
|
+
select: { id: true, title: true, status: true },
|
|
595
|
+
},
|
|
596
|
+
},
|
|
597
|
+
}
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
All three (`where`, `create`, `update`) are required. Using `data` instead of `create`/`update` is rejected.
|
|
601
|
+
|
|
602
|
+
### Bulk mutation safety
|
|
603
|
+
|
|
604
|
+
`updateMany`, `updateManyAndReturn`, and `deleteMany` require `where` in the shape:
|
|
605
|
+
```ts
|
|
606
|
+
const userConfig = {
|
|
607
|
+
deleteMany: {
|
|
608
|
+
shape: {
|
|
609
|
+
where: { isActive: { equals: true }, role: { equals: true } },
|
|
610
|
+
},
|
|
611
|
+
},
|
|
612
|
+
updateMany: {
|
|
613
|
+
shape: {
|
|
614
|
+
data: { isActive: true },
|
|
615
|
+
where: { role: { equals: true } },
|
|
616
|
+
},
|
|
617
|
+
},
|
|
618
|
+
}
|
|
619
|
+
```
|
|
620
|
+
|
|
621
|
+
A shape without `where` on these methods is rejected. Empty resolved where at runtime is also rejected.
|
|
622
|
+
|
|
623
|
+
### Tenant isolation with guard shapes
|
|
624
|
+
|
|
625
|
+
When the guard extension is configured with scope context, tenant filters are injected automatically into all top-level operations on scoped models. Guard shapes and scope work together:
|
|
626
|
+
```prisma
|
|
627
|
+
/// @scope-root
|
|
628
|
+
model Tenant {
|
|
629
|
+
id String @id @default(cuid())
|
|
630
|
+
name String
|
|
631
|
+
projects Project[]
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
model Project {
|
|
635
|
+
id String @id @default(cuid())
|
|
636
|
+
title String
|
|
637
|
+
tenantId String
|
|
638
|
+
tenant Tenant @relation(fields: [tenantId], references: [id])
|
|
639
|
+
}
|
|
640
|
+
```
|
|
641
|
+
```ts
|
|
642
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
643
|
+
import { guard } from './generated/guard/client'
|
|
644
|
+
|
|
645
|
+
const store = new AsyncLocalStorage<{ tenantId: string }>()
|
|
646
|
+
|
|
647
|
+
const prisma = new PrismaClient().$extends(
|
|
648
|
+
guard.extension(() => ({
|
|
649
|
+
Tenant: store.getStore()?.tenantId,
|
|
650
|
+
}))
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
app.use((req, res, next) => {
|
|
654
|
+
const tenantId = req.headers['x-tenant-id'] as string
|
|
655
|
+
store.run({ tenantId }, () => {
|
|
656
|
+
req.prisma = prisma
|
|
657
|
+
next()
|
|
658
|
+
})
|
|
659
|
+
})
|
|
660
|
+
|
|
661
|
+
app.use('/', ProjectRouter({
|
|
662
|
+
findMany: {
|
|
663
|
+
shape: {
|
|
664
|
+
where: { title: { contains: true } },
|
|
665
|
+
take: { max: 50 },
|
|
666
|
+
},
|
|
667
|
+
},
|
|
668
|
+
create: {
|
|
669
|
+
shape: {
|
|
670
|
+
data: { title: true },
|
|
671
|
+
},
|
|
672
|
+
},
|
|
673
|
+
}))
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
The scope extension handles tenant isolation at the query level:
|
|
677
|
+
|
|
678
|
+
- Reads: `AND tenantId = ?` is injected into where
|
|
679
|
+
- Creates: `tenantId` is injected into data (the scope FK does not need to be in the data shape)
|
|
680
|
+
- Updates/deletes: `tenantId` condition is merged into where, scope FK is stripped from data
|
|
681
|
+
- Upsert: scope condition in where, FK injected into create data, FK stripped from update data
|
|
682
|
+
|
|
683
|
+
The data shape for `create` above only lists `title`. The `tenantId` field is injected by the scope extension automatically — the create completeness check accounts for scope foreign keys.
|
|
684
|
+
|
|
685
|
+
### Supported shape keys
|
|
686
|
+
|
|
687
|
+
For reads: `where`, `include`, `select`, `orderBy`, `cursor`, `take`, `skip`, `distinct`, `_count`, `_avg`, `_sum`, `_min`, `_max`, `by`, `having`
|
|
688
|
+
|
|
689
|
+
For writes: `data`, `where`, `select`, `include` (select/include only on methods that return records)
|
|
690
|
+
|
|
691
|
+
For upsert: `where`, `create`, `update`, `select`, `include`
|
|
692
|
+
|
|
693
|
+
### Guard error handling
|
|
694
|
+
|
|
695
|
+
Guard errors are mapped to HTTP status codes by the generated error-handling middleware:
|
|
696
|
+
|
|
697
|
+
| Error type | HTTP status | When |
|
|
698
|
+
| ------------- | ----------- | ----------------------------------------------------------------- |
|
|
699
|
+
| `ShapeError` | 400 | Invalid shape config, unknown fields, body validation, type errors |
|
|
700
|
+
| `CallerError` | 400 | Missing/unknown/ambiguous caller, caller in body |
|
|
701
|
+
| `PolicyError` | 403 | Scope denied, missing tenant context, rejected findUnique |
|
|
702
|
+
|
|
703
|
+
All errors return `{ "message": "..." }` in the response body.
|
|
704
|
+
|
|
705
|
+
### Complete guard example
|
|
706
|
+
```ts
|
|
707
|
+
import express from 'express'
|
|
708
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
709
|
+
import { PrismaClient } from '@prisma/client'
|
|
710
|
+
import { guard } from './generated/guard/client'
|
|
711
|
+
import { force } from 'prisma-guard'
|
|
712
|
+
import { UserRouter } from './generated/User/UserRouter'
|
|
713
|
+
import { ProjectRouter } from './generated/Project/ProjectRouter'
|
|
714
|
+
|
|
715
|
+
const store = new AsyncLocalStorage<{ tenantId: string; role: string }>()
|
|
716
|
+
|
|
717
|
+
const prisma = new PrismaClient().$extends(
|
|
718
|
+
guard.extension(() => ({
|
|
719
|
+
Tenant: store.getStore()?.tenantId,
|
|
720
|
+
}))
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
const app = express()
|
|
724
|
+
|
|
725
|
+
app.use((req, res, next) => {
|
|
726
|
+
const tenantId = req.headers['x-tenant-id'] as string
|
|
727
|
+
const role = req.headers['x-role'] as string || 'viewer'
|
|
728
|
+
store.run({ tenantId, role }, () => {
|
|
729
|
+
req.prisma = prisma
|
|
730
|
+
next()
|
|
731
|
+
})
|
|
732
|
+
})
|
|
733
|
+
|
|
734
|
+
app.use('/', ProjectRouter({
|
|
735
|
+
findMany: {
|
|
736
|
+
shape: {
|
|
737
|
+
admin: {
|
|
738
|
+
where: { title: { contains: true }, status: { equals: true } },
|
|
739
|
+
include: { members: true },
|
|
740
|
+
orderBy: { createdAt: true },
|
|
741
|
+
take: { max: 200 },
|
|
742
|
+
skip: true,
|
|
743
|
+
},
|
|
744
|
+
viewer: {
|
|
745
|
+
where: {
|
|
746
|
+
title: { contains: true },
|
|
747
|
+
status: { equals: 'published' },
|
|
748
|
+
isDeleted: { equals: false },
|
|
749
|
+
},
|
|
750
|
+
select: { id: true, title: true, createdAt: true },
|
|
751
|
+
take: { max: 50, default: 20 },
|
|
752
|
+
},
|
|
753
|
+
},
|
|
754
|
+
},
|
|
755
|
+
create: {
|
|
756
|
+
shape: {
|
|
757
|
+
admin: {
|
|
758
|
+
data: { title: true, status: true, priority: true },
|
|
759
|
+
include: { members: true },
|
|
760
|
+
},
|
|
761
|
+
viewer: {
|
|
762
|
+
data: { title: true, status: 'draft', priority: 1 },
|
|
763
|
+
},
|
|
764
|
+
},
|
|
765
|
+
},
|
|
766
|
+
update: {
|
|
767
|
+
shape: {
|
|
768
|
+
admin: {
|
|
769
|
+
data: { title: true, status: true, priority: true },
|
|
770
|
+
where: { id: { equals: true } },
|
|
771
|
+
},
|
|
772
|
+
viewer: {
|
|
773
|
+
data: { title: true },
|
|
774
|
+
where: { id: { equals: true } },
|
|
775
|
+
},
|
|
776
|
+
},
|
|
777
|
+
},
|
|
778
|
+
delete: {
|
|
779
|
+
shape: {
|
|
780
|
+
admin: {
|
|
781
|
+
where: { id: { equals: true } },
|
|
782
|
+
},
|
|
783
|
+
},
|
|
784
|
+
},
|
|
785
|
+
guard: {
|
|
786
|
+
resolveVariant: (req) => {
|
|
787
|
+
const ctx = store.getStore()
|
|
788
|
+
return ctx?.role === 'admin' ? 'admin' : 'viewer'
|
|
789
|
+
},
|
|
790
|
+
},
|
|
791
|
+
}))
|
|
792
|
+
|
|
793
|
+
app.listen(3000)
|
|
794
|
+
```
|
|
795
|
+
|
|
796
|
+
In this setup:
|
|
797
|
+
|
|
798
|
+
- Admins can filter by any allowed field, include relations, and take up to 200 rows
|
|
799
|
+
- Viewers can only see published, non-deleted projects with a restricted field set
|
|
800
|
+
- Create: admins set any allowed field; viewers always create drafts with priority 1
|
|
801
|
+
- Delete: only admins can delete; viewers hitting the delete endpoint get a `CallerError` because there is no `viewer` shape for delete
|
|
802
|
+
- Tenant isolation is automatic — every query is scoped to the tenant from `x-tenant-id`
|
|
158
803
|
|
|
159
804
|
## Request body format
|
|
160
805
|
|
package/package.json
CHANGED