buro26-strapi-graphql 0.2.0 → 0.3.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 +53 -8
- package/dist/server/utils/field-builder/builder.js +67 -14
- package/dist/server/utils/field-builder/types.d.ts +20 -10
- package/dist/server/utils/loaders.d.ts +29 -13
- package/dist/server/utils/loaders.js +74 -18
- package/dist/server/utils/registry/global-registry.d.ts +13 -2
- package/dist/server/utils/registry/global-registry.js +13 -2
- package/dist/server/utils/strapi-compat.d.ts +8 -3
- package/dist/server/utils/strapi-compat.js +16 -7
- package/dist/tsconfig.server.tsbuildinfo +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -50,7 +50,7 @@ export default createGraphQLFieldBuilder()
|
|
|
50
50
|
|
|
51
51
|
Use `.relation()` to define (or protect) a relation field. It produces exactly the field Strapi generates:
|
|
52
52
|
the same output type, filter/pagination arguments, resolver (with query validation and output sanitizing)
|
|
53
|
-
and `<target>.find` permission scope, on **Strapi v4 and v5**.
|
|
53
|
+
and, for relations, the `<target>.find` permission scope, on **Strapi v4 and v5**.
|
|
54
54
|
|
|
55
55
|
```typescript
|
|
56
56
|
export default createGraphQLFieldBuilder()
|
|
@@ -64,8 +64,22 @@ export default createGraphQLFieldBuilder()
|
|
|
64
64
|
| to-one | `XEntityResponse` | `X` |
|
|
65
65
|
| to-many | `XRelationResponseCollection` | `[X]!` **and** `<field>_connection` (both get the same auth/middleware) |
|
|
66
66
|
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
Media attributes (`type: 'media'`) work too: `.relation('api::customer.customer', 'avatar')` uses
|
|
68
|
+
`UploadFileEntityResponse` (v4) or `UploadFile` (v5); a `multiple: true` media field uses
|
|
69
|
+
`UploadFileRelationResponseCollection` (v4) or `[UploadFile]!` plus `<field>_connection` (v5). Strapi
|
|
70
|
+
attaches no permission scope to the media fields it generates, so `.relation()` adds none either.
|
|
71
|
+
|
|
72
|
+
For to-one **and** to-many relations and for single or multiple media you can pass `{ batch: true }` to load
|
|
73
|
+
the relation of all parents in the request with a single query (see [Data loading](#-data-loading)). A batched
|
|
74
|
+
to-many field returns the same relation response collection as Strapi's own resolver; on v5 both the list
|
|
75
|
+
field and `<field>_connection` use it.
|
|
76
|
+
|
|
77
|
+
Batched loads cannot apply per-parent query arguments. When a client passes `filters`, `pagination`, `sort` or
|
|
78
|
+
(v4 only) `publicationState`, that call falls back to Strapi's normal per-parent association resolver, so the
|
|
79
|
+
arguments keep working; only those calls lose the batching benefit.
|
|
80
|
+
|
|
81
|
+
Batched relations go through the [relation loader](#-data-loading), so draft & publish content is loaded as the
|
|
82
|
+
published version. Media are files, so draft & publish never applies to them.
|
|
69
83
|
|
|
70
84
|
---
|
|
71
85
|
|
|
@@ -106,15 +120,31 @@ import { loaders, sanitizeOutput } from 'buro26-strapi-graphql';
|
|
|
106
120
|
// One findMany ... where id IN (...) for all parents in the request
|
|
107
121
|
const order = await loaders(ctx).entity(ORDER, { populate: { customer: true } }).load(parent.id);
|
|
108
122
|
|
|
109
|
-
// The relation of many parents in one query
|
|
123
|
+
// The relation of many parents in one query. Raw rows: sanitize before returning them.
|
|
110
124
|
const customer = await loaders(ctx).relation(ORDER, 'customer').load(parent.id);
|
|
111
125
|
|
|
126
|
+
// Media work the same way: a file row (single) or an array (multiple)
|
|
127
|
+
const avatar = await loaders(ctx).relation(CUSTOMER, 'avatar').load(parent.id);
|
|
128
|
+
|
|
129
|
+
// Or let the loader sanitize every row for the content API
|
|
130
|
+
const safeOrder = await loaders(ctx).entity(ORDER, { sanitize: true }).load(parent.id);
|
|
131
|
+
|
|
112
132
|
// Your own batch function, e.g. for an external API
|
|
113
133
|
const profile = await loaders(ctx).custom('profile', (ids: readonly number[]) => fetchProfiles(ids)).load(parent.id);
|
|
114
134
|
```
|
|
115
135
|
|
|
116
|
-
Loaders use `strapi.db.query()` (identical in v4 and v5) and return **unsanitized** database rows
|
|
117
|
-
|
|
136
|
+
Loaders use `strapi.db.query()` (identical in v4 and v5) and return **unsanitized** database rows by default,
|
|
137
|
+
including private fields. Pass `{ sanitize: true }` to run each row through `sanitizeOutput` first, or sanitize
|
|
138
|
+
the rows yourself. For draft & publish content types they load the published version unless `status: 'draft'`
|
|
139
|
+
is passed; files have no draft & publish status.
|
|
140
|
+
|
|
141
|
+
Media attributes are stored as a morph relation to `plugin::upload.file`. `relation()` populates them in the
|
|
142
|
+
same query and returns the file row for a single media attribute, or an array (empty when there is none) for
|
|
143
|
+
`multiple: true`. With `{ sanitize: true }` files are sanitized against `plugin::upload.file`.
|
|
144
|
+
|
|
145
|
+
Loaders are cached per request and per options value. Options are compared by value: property order does not
|
|
146
|
+
matter and `select` is treated as a set, so `{ select: ['id', 'title'] }` and `{ select: ['title', 'id'] }` share
|
|
147
|
+
one loader. Loaders with a different `populate` never share a batch, because they load different rows.
|
|
118
148
|
|
|
119
149
|
---
|
|
120
150
|
|
|
@@ -247,10 +277,19 @@ Global middleware and authorization are matched when the GraphQL schema is built
|
|
|
247
277
|
the order in which files are loaded. The matcher receives `{ type, fieldName, tags, contentType, isBuilderField }`.
|
|
248
278
|
|
|
249
279
|
By default globals only apply to fields created with the field builder. Pass `{ includeNonBuilderFields: true }`
|
|
250
|
-
to also apply them to Strapi's generated fields and plain Nexus fields
|
|
280
|
+
to also apply them to Strapi's generated fields and plain Nexus fields.
|
|
281
|
+
|
|
282
|
+
> **Cost: globals run once per resolved field, not once per request.** A query that resolves 50 fields runs every
|
|
283
|
+
> matching global check and middleware 50 times. Keep them cheap and free of per-request side effects. Rate
|
|
284
|
+
> limiting, request logging and similar per-request concerns belong in a Koa/Strapi middleware:
|
|
251
285
|
|
|
252
286
|
```typescript
|
|
253
|
-
|
|
287
|
+
// src/middlewares/rate-limit.ts — runs once per HTTP request, before GraphQL resolves anything
|
|
288
|
+
export default () => async (ctx, next) => {
|
|
289
|
+
const key = ctx.state.user?.id ?? ctx.ip;
|
|
290
|
+
if (!(await hits.allow(key))) return ctx.throw(429, 'Too many requests');
|
|
291
|
+
return next();
|
|
292
|
+
};
|
|
254
293
|
```
|
|
255
294
|
|
|
256
295
|
**Example:**
|
|
@@ -273,6 +312,12 @@ registerGlobalAuthorize(
|
|
|
273
312
|
async (parent, args, ctx) => ctx.state.user?.isAdmin
|
|
274
313
|
);
|
|
275
314
|
|
|
315
|
+
// Or for every field of one content type, e.g. requiring a subscription
|
|
316
|
+
registerGlobalAuthorize(
|
|
317
|
+
({ contentType }) => contentType === 'api::customer.customer',
|
|
318
|
+
(_parent, _args, ctx) => Boolean(ctx.state.user?.subscription)
|
|
319
|
+
);
|
|
320
|
+
|
|
276
321
|
// Now define fields that will use these global middlewares/authorizations
|
|
277
322
|
export default createGraphQLFieldBuilder('Query')
|
|
278
323
|
.fieldName('deleteUser')
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.resetBuiltFields = exports.createGraphQLFieldBuilder = void 0;
|
|
3
|
+
exports.batchedRelationResolver = exports.resetBuiltFields = exports.createGraphQLFieldBuilder = void 0;
|
|
4
4
|
const nexus_1 = require("nexus");
|
|
5
5
|
const utils_1 = require("@strapi/utils");
|
|
6
6
|
const immer_1 = require("immer");
|
|
@@ -482,27 +482,80 @@ function composeResolver(state, baseResolver) {
|
|
|
482
482
|
return withTracing(withHooks, (_a = options.traceHandlers) !== null && _a !== void 0 ? _a : []);
|
|
483
483
|
}
|
|
484
484
|
/**
|
|
485
|
-
*
|
|
486
|
-
*
|
|
485
|
+
* Arguments of Strapi's relation fields that shape the related query per parent. A batched load cannot
|
|
486
|
+
* apply them, so a call that passes any of them uses Strapi's own association resolver instead.
|
|
487
|
+
*
|
|
488
|
+
* `publicationState` only exists on Strapi v4 to-many relation fields.
|
|
489
|
+
*/
|
|
490
|
+
const RELATION_QUERY_ARGS = ['filters', 'pagination', 'sort', 'publicationState'];
|
|
491
|
+
/**
|
|
492
|
+
* True when an argument value changes the related query. Strapi fills in defaults on every call
|
|
493
|
+
* (`pagination: {}`, `sort: []` and, on v4, `publicationState: 'live'`); those count as not passed.
|
|
494
|
+
*/
|
|
495
|
+
function isEffectiveRelationArg(name, value) {
|
|
496
|
+
if (value === undefined || value === null)
|
|
497
|
+
return false;
|
|
498
|
+
if (Array.isArray(value))
|
|
499
|
+
return value.length > 0;
|
|
500
|
+
if (typeof value === 'object')
|
|
501
|
+
return Object.keys(value).length > 0;
|
|
502
|
+
if (name === 'publicationState')
|
|
503
|
+
return value !== 'live';
|
|
504
|
+
return true;
|
|
505
|
+
}
|
|
506
|
+
/** True when the client passed filters, pagination, sort or publication state to the relation field. */
|
|
507
|
+
function hasRelationQueryArgs(args) {
|
|
508
|
+
if (!args)
|
|
509
|
+
return false;
|
|
510
|
+
return RELATION_QUERY_ARGS.some((name) => isEffectiveRelationArg(name, args[name]));
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Resolver for `.relation(..., { batch: true })`: loads the relation or media attribute of all parents
|
|
514
|
+
* in the request with one query, sanitizes the rows like Strapi does and returns the shape the relation
|
|
515
|
+
* field expects.
|
|
516
|
+
*
|
|
517
|
+
* To-one resolves to an entity response; to-many resolves to a relation response collection
|
|
518
|
+
* (`{ nodes }`, on both v4 and v5 — the v5 list field unwraps `nodes`). Media resolve against
|
|
519
|
+
* `plugin::upload.file`; `multiple` decides the shape.
|
|
520
|
+
*
|
|
521
|
+
* Filters, pagination, sort and publication state cannot be applied to a batched load, so a call that
|
|
522
|
+
* passes them falls back to Strapi's per-parent association resolver. Draft & publish content is
|
|
523
|
+
* loaded through the relation loader, which defaults to the published version.
|
|
524
|
+
*
|
|
525
|
+
* @internal Exported for tests.
|
|
487
526
|
*/
|
|
488
527
|
function batchedRelationResolver({ contentType, attribute }) {
|
|
489
528
|
var _a, _b;
|
|
490
529
|
const attr = (_b = (_a = strapi.getModel(contentType)) === null || _a === void 0 ? void 0 : _a.attributes) === null || _b === void 0 ? void 0 : _b[attribute];
|
|
491
|
-
|
|
492
|
-
|
|
530
|
+
const isMedia = (attr === null || attr === void 0 ? void 0 : attr.type) === 'media';
|
|
531
|
+
if (!attr || (!isMedia && attr.type !== 'relation')) {
|
|
532
|
+
throw new utils_1.errors.ApplicationError(`"${contentType}.${attribute}" is not a relation or media attribute`);
|
|
493
533
|
}
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
}
|
|
497
|
-
//
|
|
498
|
-
|
|
499
|
-
const
|
|
500
|
-
|
|
534
|
+
const targetUID = isMedia ? 'plugin::upload.file' : attr.target;
|
|
535
|
+
const isToMany = isMedia ? Boolean(attr.multiple) : String(attr.relation).endsWith('Many');
|
|
536
|
+
// Returns Strapi's response wrapper (`{ value }` / `{ nodes }`), like `buildAssociationResolver`;
|
|
537
|
+
// the relation field spec unwraps the to-one value on Strapi v5.
|
|
538
|
+
const { toEntityResponse, toEntityResponseCollection } = strapi.plugin('graphql').service('format').returnTypes;
|
|
539
|
+
const associationResolver = strapi
|
|
540
|
+
.plugin('graphql')
|
|
541
|
+
.service('builders')
|
|
542
|
+
.get('content-api')
|
|
543
|
+
.buildAssociationResolver({ contentTypeUID: contentType, attributeName: attribute, strapi });
|
|
544
|
+
return async (parent, args, context, info) => {
|
|
545
|
+
if (hasRelationQueryArgs(args)) {
|
|
546
|
+
return associationResolver(parent, args, context, info);
|
|
547
|
+
}
|
|
501
548
|
const related = (parent === null || parent === void 0 ? void 0 : parent.id) ? await (0, loaders_1.loaders)(context).relation(contentType, attribute).load(parent.id) : null;
|
|
502
|
-
|
|
503
|
-
|
|
549
|
+
if (!isToMany) {
|
|
550
|
+
const sanitized = related ? await (0, strapi_compat_1.sanitizeOutput)(related, targetUID, context) : null;
|
|
551
|
+
return toEntityResponse(sanitized, { args: {}, resourceUID: targetUID });
|
|
552
|
+
}
|
|
553
|
+
const rows = Array.isArray(related) ? related : [];
|
|
554
|
+
const sanitized = await (0, strapi_compat_1.sanitizeOutput)(rows, targetUID, context);
|
|
555
|
+
return toEntityResponseCollection(sanitized !== null && sanitized !== void 0 ? sanitized : [], { args: {}, resourceUID: targetUID });
|
|
504
556
|
};
|
|
505
557
|
}
|
|
558
|
+
exports.batchedRelationResolver = batchedRelationResolver;
|
|
506
559
|
function defaultTraceHandler({ type, fieldName, durationMs, args, error }) {
|
|
507
560
|
// Colors
|
|
508
561
|
const green = '\x1b[32m';
|
|
@@ -83,11 +83,14 @@ export type RelationOptions = {
|
|
|
83
83
|
/** Relation attribute name on that content type, e.g. `customer` */
|
|
84
84
|
attribute: string;
|
|
85
85
|
/**
|
|
86
|
-
* Resolve
|
|
86
|
+
* Resolve the relation with the per-request relation loader: one query for all parents in the
|
|
87
87
|
* request instead of one query per parent. Output is sanitized for the content API, like Strapi's own
|
|
88
|
-
* resolver does.
|
|
88
|
+
* resolver does. Works for to-one and to-many relations; a to-many field returns the same relation
|
|
89
|
+
* response collection as Strapi.
|
|
89
90
|
*
|
|
90
|
-
*
|
|
91
|
+
* Batched loads cannot apply per-parent query arguments: when a client passes `filters`,
|
|
92
|
+
* `pagination`, `sort` or (v4) `publicationState`, that call falls back to Strapi's normal
|
|
93
|
+
* per-parent association resolver.
|
|
91
94
|
*
|
|
92
95
|
* @default false
|
|
93
96
|
* @see {@link loaders}
|
|
@@ -134,9 +137,12 @@ export interface FieldExtensionBuilder<Type = any, TArgs = any, TOutput = Type>
|
|
|
134
137
|
*/
|
|
135
138
|
rootType<N extends string>(type: N): FieldExtensionBuilder<ParentOf<N, Type>, TArgs, TOutput>;
|
|
136
139
|
/**
|
|
137
|
-
* Define the field as a relation of a Strapi content type. It gets exactly the
|
|
138
|
-
* arguments, resolver (query validation, output sanitizing, draft & publish
|
|
139
|
-
* permission scope of the field Strapi generates, on Strapi v4 and v5.
|
|
140
|
+
* Define the field as a relation or media attribute of a Strapi content type. It gets exactly the
|
|
141
|
+
* type, filter/pagination arguments, resolver (query validation, output sanitizing, draft & publish
|
|
142
|
+
* handling) and permission scope of the field Strapi generates, on Strapi v4 and v5.
|
|
143
|
+
*
|
|
144
|
+
* Media fields resolve against `plugin::upload.file`. Strapi attaches no permission scope to the
|
|
145
|
+
* media fields it generates, so neither does this builder.
|
|
140
146
|
*
|
|
141
147
|
* Sets {@link FieldExtensionBuilder.fieldName | fieldName} to `attribute`; `outputType` and `resolver`
|
|
142
148
|
* are optional (a custom `resolver` replaces Strapi's association resolver). Combine with
|
|
@@ -150,18 +156,22 @@ export interface FieldExtensionBuilder<Type = any, TArgs = any, TOutput = Type>
|
|
|
150
156
|
* On v5 both to-many fields get the same authorization and middleware. Note that the v5 list field is
|
|
151
157
|
* non-null: when `.authorize()` denies it, GraphQL nulls the parent object (standard error propagation).
|
|
152
158
|
*
|
|
153
|
-
* @param contentType - UID of the content type that owns the relation.
|
|
154
|
-
* @param attribute - Name of a regular (non-polymorphic) relation attribute.
|
|
159
|
+
* @param contentType - UID of the content type that owns the relation or media attribute.
|
|
160
|
+
* @param attribute - Name of a regular (non-polymorphic) relation or media attribute.
|
|
155
161
|
* @param options - See {@link RelationOptions}.
|
|
156
162
|
* @throws Error when a relation was already set, or `fieldName` was set to a different name.
|
|
157
|
-
* @throws ApplicationError at schema build when the attribute is not a relation
|
|
158
|
-
* a to-many relation.
|
|
163
|
+
* @throws ApplicationError at schema build when the attribute is not a relation or media attribute.
|
|
159
164
|
*
|
|
160
165
|
* @example
|
|
161
166
|
* createGraphQLFieldBuilder()
|
|
162
167
|
* .rootType('Order')
|
|
163
168
|
* .relation('api::order.order', 'customer', { batch: true })
|
|
164
169
|
* .authorize(isOrderCustomer)
|
|
170
|
+
*
|
|
171
|
+
* @example
|
|
172
|
+
* createGraphQLFieldBuilder()
|
|
173
|
+
* .rootType('Customer')
|
|
174
|
+
* .relation('api::customer.customer', 'avatar', { batch: true })
|
|
165
175
|
*/
|
|
166
176
|
relation(contentType: string, attribute: string, options?: Pick<RelationOptions, 'batch'>): FieldExtensionBuilder<Type, TArgs, TOutput>;
|
|
167
177
|
/**
|
|
@@ -1,10 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Per-request batching and caching for GraphQL resolvers (DataLoader-style, without the dependency).
|
|
3
|
-
*
|
|
4
|
-
* Loads requested in the same tick are combined into one batch call; results are cached for the rest
|
|
5
|
-
* of the request. The cache lives on the GraphQL context object, which Strapi creates per request in
|
|
6
|
-
* both v4 and v5, so nothing leaks between requests or users.
|
|
7
|
-
*/
|
|
8
1
|
type AnyRecord = Record<string, any>;
|
|
9
2
|
/**
|
|
10
3
|
* Loads values for many keys at once. Must return one result per key, **in the same order**; return an
|
|
@@ -40,6 +33,9 @@ export declare function createLoader<K, V>(batch: BatchFunction<K, V>, { maxBatc
|
|
|
40
33
|
}): Loader<K, V>;
|
|
41
34
|
/**
|
|
42
35
|
* Options for {@link RequestLoaders.entity}. Loaders with different options are cached separately.
|
|
36
|
+
*
|
|
37
|
+
* Options are compared by value, not by reference: key order does not matter and `select` is treated
|
|
38
|
+
* as a set. Loaders with a different `populate` never share a batch, because the rows they load differ.
|
|
43
39
|
*/
|
|
44
40
|
export type EntityLoaderOptions = {
|
|
45
41
|
/** Columns to select (default: all) */
|
|
@@ -51,6 +47,15 @@ export type EntityLoaderOptions = {
|
|
|
51
47
|
* @default 'published'
|
|
52
48
|
*/
|
|
53
49
|
status?: 'published' | 'draft';
|
|
50
|
+
/**
|
|
51
|
+
* Pass every loaded row through {@link sanitizeOutput} before resolving it, so private fields and
|
|
52
|
+
* fields the current auth may not read are removed.
|
|
53
|
+
*
|
|
54
|
+
* Sanitizing needs the content type schema, so this throws for an unknown `uid`.
|
|
55
|
+
*
|
|
56
|
+
* @default false
|
|
57
|
+
*/
|
|
58
|
+
sanitize?: boolean;
|
|
54
59
|
};
|
|
55
60
|
/**
|
|
56
61
|
* Loaders scoped to one GraphQL request, returned by {@link loaders}.
|
|
@@ -60,15 +65,21 @@ export interface RequestLoaders {
|
|
|
60
65
|
* Load entities of a content type by database id, batched into one `findMany` per tick.
|
|
61
66
|
* Resolves to `null` for ids that do not exist.
|
|
62
67
|
*
|
|
63
|
-
* Returns raw database rows
|
|
64
|
-
*
|
|
68
|
+
* **Returns raw database rows, including private fields and fields the current auth may not read.**
|
|
69
|
+
* Pass `{ sanitize: true }` to run every row through {@link sanitizeOutput} first, or sanitize the
|
|
70
|
+
* rows yourself before returning them to clients.
|
|
65
71
|
*/
|
|
66
72
|
entity<T = AnyRecord>(uid: string, options?: EntityLoaderOptions): Loader<number | string, T | null>;
|
|
67
73
|
/**
|
|
68
|
-
* Load a relation of many parents with one query on the parent content type.
|
|
69
|
-
* Resolves to the related entity or `null` (to-one), or an array (to-many
|
|
74
|
+
* Load a relation or media attribute of many parents with one query on the parent content type.
|
|
75
|
+
* Resolves to the related entity or `null` (to-one, single media), or an array (to-many, multiple
|
|
76
|
+
* media; empty when there is none).
|
|
70
77
|
*
|
|
71
|
-
*
|
|
78
|
+
* **Returns raw database rows, including private fields and fields the current auth may not read.**
|
|
79
|
+
* Pass `{ sanitize: true }` to run every related row through {@link sanitizeOutput} first, or
|
|
80
|
+
* sanitize the rows yourself before returning them to clients.
|
|
81
|
+
*
|
|
82
|
+
* @throws Error when `attribute` is not a relation or media attribute of `uid`.
|
|
72
83
|
*/
|
|
73
84
|
relation<T = AnyRecord>(uid: string, attribute: string, options?: Omit<EntityLoaderOptions, 'select'>): Loader<number | string, T>;
|
|
74
85
|
/**
|
|
@@ -81,7 +92,11 @@ export interface RequestLoaders {
|
|
|
81
92
|
* Loaders scoped to the current GraphQL request.
|
|
82
93
|
*
|
|
83
94
|
* Uses `strapi.db.query()` (same API in v4 and v5). That is the query engine, not the entity/document
|
|
84
|
-
* service: lifecycle-free and unsanitized, so it is meant for reading data inside resolvers.
|
|
95
|
+
* service: lifecycle-free and unsanitized, so it is meant for reading data inside resolvers. Add
|
|
96
|
+
* `{ sanitize: true }` to return rows that are safe for the content API.
|
|
97
|
+
*
|
|
98
|
+
* Loaders are cached per request and per options value: options are compared by value (key order and
|
|
99
|
+
* `select` order do not matter), and loaders with a different `populate` do not share batches.
|
|
85
100
|
*
|
|
86
101
|
* @param context - The resolver's third argument (`ctx`).
|
|
87
102
|
* @throws Error when `context` is not an object.
|
|
@@ -89,6 +104,7 @@ export interface RequestLoaders {
|
|
|
89
104
|
* @example
|
|
90
105
|
* const order = await loaders(ctx).entity(ORDER, { populate: { customer: true } }).load(parent.id)
|
|
91
106
|
* const customer = await loaders(ctx).relation(ORDER, 'customer').load(parent.id)
|
|
107
|
+
* const safeOrder = await loaders(ctx).entity(ORDER, { sanitize: true }).load(parent.id)
|
|
92
108
|
*/
|
|
93
109
|
export declare function loaders(context: object): RequestLoaders;
|
|
94
110
|
export {};
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.loaders = exports.createLoader = void 0;
|
|
2
4
|
/**
|
|
3
5
|
* Per-request batching and caching for GraphQL resolvers (DataLoader-style, without the dependency).
|
|
4
6
|
*
|
|
@@ -6,8 +8,7 @@
|
|
|
6
8
|
* of the request. The cache lives on the GraphQL context object, which Strapi creates per request in
|
|
7
9
|
* both v4 and v5, so nothing leaks between requests or users.
|
|
8
10
|
*/
|
|
9
|
-
|
|
10
|
-
exports.loaders = exports.createLoader = void 0;
|
|
11
|
+
const strapi_compat_1 = require("./strapi-compat");
|
|
11
12
|
/**
|
|
12
13
|
* Create a batching loader. Keys are compared by `String(key)`, so `1` and `'1'` share a cache entry.
|
|
13
14
|
* Failed batches are not cached, so a later `load` retries.
|
|
@@ -85,6 +86,41 @@ function createLoader(batch, { maxBatchSize = 500 } = {}) {
|
|
|
85
86
|
}
|
|
86
87
|
exports.createLoader = createLoader;
|
|
87
88
|
const requestLoaders = new WeakMap();
|
|
89
|
+
/**
|
|
90
|
+
* Normalize a value for a cache key: object keys are sorted recursively, `select` arrays are sorted,
|
|
91
|
+
* and `undefined` properties are dropped.
|
|
92
|
+
*/
|
|
93
|
+
const normalizeCacheValue = (value) => {
|
|
94
|
+
if (Array.isArray(value))
|
|
95
|
+
return value.map(normalizeCacheValue);
|
|
96
|
+
if (!value || typeof value !== 'object')
|
|
97
|
+
return value;
|
|
98
|
+
const entries = Object.entries(value)
|
|
99
|
+
.filter(([, entryValue]) => entryValue !== undefined)
|
|
100
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
101
|
+
.map(([key, entryValue]) => [
|
|
102
|
+
key,
|
|
103
|
+
key === 'select' && Array.isArray(entryValue)
|
|
104
|
+
? [...entryValue].sort()
|
|
105
|
+
: normalizeCacheValue(entryValue)
|
|
106
|
+
]);
|
|
107
|
+
return Object.fromEntries(entries);
|
|
108
|
+
};
|
|
109
|
+
/**
|
|
110
|
+
* Stable cache key for loader options, so equivalent options share one loader regardless of property
|
|
111
|
+
* order (`{ status: 'draft', populate: {...} }` and `{ populate: {...}, status: 'draft' }`) or column
|
|
112
|
+
* order in `select`.
|
|
113
|
+
*/
|
|
114
|
+
const optionsCacheKey = (options) => JSON.stringify(normalizeCacheValue(options));
|
|
115
|
+
/** Sanitize one related value: an array for to-many, a single row for to-one, or `null`. */
|
|
116
|
+
const sanitizeRelationValue = async (value, targetUID, context) => {
|
|
117
|
+
if (value == null)
|
|
118
|
+
return value;
|
|
119
|
+
if (Array.isArray(value)) {
|
|
120
|
+
return Promise.all(value.map((row) => (0, strapi_compat_1.sanitizeOutput)(row, targetUID, context)));
|
|
121
|
+
}
|
|
122
|
+
return (0, strapi_compat_1.sanitizeOutput)(value, targetUID, context);
|
|
123
|
+
};
|
|
88
124
|
const hasDraftAndPublish = (uid) => { var _a, _b; return Boolean((_b = (_a = strapi.getModel(uid)) === null || _a === void 0 ? void 0 : _a.options) === null || _b === void 0 ? void 0 : _b.draftAndPublish); };
|
|
89
125
|
const statusWhere = (uid, status) => {
|
|
90
126
|
if (!hasDraftAndPublish(uid))
|
|
@@ -95,7 +131,11 @@ const statusWhere = (uid, status) => {
|
|
|
95
131
|
* Loaders scoped to the current GraphQL request.
|
|
96
132
|
*
|
|
97
133
|
* Uses `strapi.db.query()` (same API in v4 and v5). That is the query engine, not the entity/document
|
|
98
|
-
* service: lifecycle-free and unsanitized, so it is meant for reading data inside resolvers.
|
|
134
|
+
* service: lifecycle-free and unsanitized, so it is meant for reading data inside resolvers. Add
|
|
135
|
+
* `{ sanitize: true }` to return rows that are safe for the content API.
|
|
136
|
+
*
|
|
137
|
+
* Loaders are cached per request and per options value: options are compared by value (key order and
|
|
138
|
+
* `select` order do not matter), and loaders with a different `populate` do not share batches.
|
|
99
139
|
*
|
|
100
140
|
* @param context - The resolver's third argument (`ctx`).
|
|
101
141
|
* @throws Error when `context` is not an object.
|
|
@@ -103,6 +143,7 @@ const statusWhere = (uid, status) => {
|
|
|
103
143
|
* @example
|
|
104
144
|
* const order = await loaders(ctx).entity(ORDER, { populate: { customer: true } }).load(parent.id)
|
|
105
145
|
* const customer = await loaders(ctx).relation(ORDER, 'customer').load(parent.id)
|
|
146
|
+
* const safeOrder = await loaders(ctx).entity(ORDER, { sanitize: true }).load(parent.id)
|
|
106
147
|
*/
|
|
107
148
|
function loaders(context) {
|
|
108
149
|
if (!context || typeof context !== 'object') {
|
|
@@ -123,7 +164,7 @@ function loaders(context) {
|
|
|
123
164
|
};
|
|
124
165
|
return {
|
|
125
166
|
entity(uid, options = {}) {
|
|
126
|
-
return getOrCreate(`entity:${uid}:${
|
|
167
|
+
return getOrCreate(`entity:${uid}:${optionsCacheKey(options)}`, () => createLoader(async (ids) => {
|
|
127
168
|
const select = options.select ? Array.from(new Set(['id', ...options.select])) : undefined;
|
|
128
169
|
const rows = await strapi.db.query(uid).findMany({
|
|
129
170
|
where: { id: { $in: ids }, ...statusWhere(uid, options.status) },
|
|
@@ -131,31 +172,46 @@ function loaders(context) {
|
|
|
131
172
|
...(options.populate && { populate: options.populate }),
|
|
132
173
|
});
|
|
133
174
|
const byId = new Map(rows.map((row) => [String(row.id), row]));
|
|
134
|
-
|
|
175
|
+
const resolved = ids.map((id) => { var _a; return (_a = byId.get(String(id))) !== null && _a !== void 0 ? _a : null; });
|
|
176
|
+
if (!options.sanitize)
|
|
177
|
+
return resolved;
|
|
178
|
+
return Promise.all(resolved.map((row) => (row ? (0, strapi_compat_1.sanitizeOutput)(row, uid, context) : row)));
|
|
135
179
|
}));
|
|
136
180
|
},
|
|
137
181
|
relation(uid, attribute, options = {}) {
|
|
138
|
-
return getOrCreate(`relation:${uid}:${attribute}:${
|
|
182
|
+
return getOrCreate(`relation:${uid}:${attribute}:${optionsCacheKey(options)}`, () => createLoader(async (ids) => {
|
|
139
183
|
var _a, _b;
|
|
140
184
|
const attr = (_b = (_a = strapi.getModel(uid)) === null || _a === void 0 ? void 0 : _a.attributes) === null || _b === void 0 ? void 0 : _b[attribute];
|
|
141
|
-
|
|
142
|
-
|
|
185
|
+
const isMedia = (attr === null || attr === void 0 ? void 0 : attr.type) === 'media';
|
|
186
|
+
if (!attr || (!isMedia && attr.type !== 'relation')) {
|
|
187
|
+
throw new Error(`[buro26-strapi-graphql] "${uid}.${attribute}" is not a relation or media attribute`);
|
|
143
188
|
}
|
|
144
|
-
|
|
189
|
+
// Media are stored as a morph relation to the upload plugin's file content type. Their
|
|
190
|
+
// target lives there, not on the attribute, and draft & publish never applies to files.
|
|
191
|
+
const targetUID = isMedia ? 'plugin::upload.file' : attr.target;
|
|
192
|
+
const isToMany = isMedia ? Boolean(attr.multiple) : String(attr.relation).endsWith('Many');
|
|
193
|
+
const nestedPopulate = options.populate && typeof options.populate === 'object' && !Array.isArray(options.populate)
|
|
194
|
+
? { populate: options.populate }
|
|
195
|
+
: {};
|
|
196
|
+
// Media always need `true`/a nested populate; their file rows have no draft & publish status.
|
|
197
|
+
const populate = isMedia
|
|
198
|
+
? Object.keys(nestedPopulate).length > 0
|
|
199
|
+
? nestedPopulate
|
|
200
|
+
: true
|
|
201
|
+
: {
|
|
202
|
+
...nestedPopulate,
|
|
203
|
+
...(hasDraftAndPublish(targetUID) && { where: statusWhere(targetUID, options.status) }),
|
|
204
|
+
};
|
|
145
205
|
const rows = await strapi.db.query(uid).findMany({
|
|
146
206
|
where: { id: { $in: ids } },
|
|
147
207
|
select: ['id'],
|
|
148
|
-
populate: {
|
|
149
|
-
[attribute]: {
|
|
150
|
-
...(options.populate && typeof options.populate === 'object' && !Array.isArray(options.populate)
|
|
151
|
-
? { populate: options.populate }
|
|
152
|
-
: {}),
|
|
153
|
-
...(hasDraftAndPublish(attr.target) && { where: statusWhere(attr.target, options.status) }),
|
|
154
|
-
},
|
|
155
|
-
},
|
|
208
|
+
populate: { [attribute]: populate },
|
|
156
209
|
});
|
|
157
210
|
const byId = new Map(rows.map((row) => [String(row.id), row[attribute]]));
|
|
158
|
-
|
|
211
|
+
const resolved = ids.map((id) => { var _a; return (_a = byId.get(String(id))) !== null && _a !== void 0 ? _a : (isToMany ? [] : null); });
|
|
212
|
+
if (!options.sanitize)
|
|
213
|
+
return resolved;
|
|
214
|
+
return Promise.all(resolved.map((value) => sanitizeRelationValue(value, targetUID, context)));
|
|
159
215
|
}));
|
|
160
216
|
},
|
|
161
217
|
custom(name, batch) {
|
|
@@ -40,6 +40,10 @@ export type GlobalRegistrationOptions = {
|
|
|
40
40
|
* first). Matched when the schema is built, so registration order relative to fields does not matter;
|
|
41
41
|
* register before Strapi's bootstrap (i.e. at module level in a `graphql` folder).
|
|
42
42
|
*
|
|
43
|
+
* **Runs once per resolved field, not once per request.** A query that resolves 50 fields runs the
|
|
44
|
+
* middleware 50 times. Per-request concerns such as rate limiting or request logging belong in a
|
|
45
|
+
* Koa/Strapi middleware, not here.
|
|
46
|
+
*
|
|
43
47
|
* @param matcher - Decides per field; see {@link FieldMatcherInfo}.
|
|
44
48
|
* @param middleware - `(next) => resolver`, as in `.middleware()`.
|
|
45
49
|
* @param options - See {@link GlobalRegistrationOptions}.
|
|
@@ -52,12 +56,19 @@ export declare function registerGlobalMiddleware(matcher: FieldMatcher, middlewa
|
|
|
52
56
|
* Register an authorization check for all fields that match. Global checks run before the field's own
|
|
53
57
|
* checks; all must return `true`. Matched when the schema is built, so registration order does not matter.
|
|
54
58
|
*
|
|
59
|
+
* **Runs once per resolved field, not once per request.** A query that resolves 50 fields runs the
|
|
60
|
+
* check 50 times, so a per-field check must stay cheap and free of per-request side effects. Rate
|
|
61
|
+
* limiting and similar per-request concerns belong in a Koa/Strapi middleware, not here.
|
|
62
|
+
*
|
|
55
63
|
* @param matcher - Decides per field; see {@link FieldMatcherInfo}.
|
|
56
64
|
* @param authorize - Return `true` to allow, `false` or an `Error` to deny.
|
|
57
65
|
* @param options - See {@link GlobalRegistrationOptions}.
|
|
58
66
|
*
|
|
59
67
|
* @example
|
|
60
|
-
* //
|
|
61
|
-
* registerGlobalAuthorize(
|
|
68
|
+
* // Require a paid subscription for every customer field
|
|
69
|
+
* registerGlobalAuthorize(
|
|
70
|
+
* ({ contentType }) => contentType === 'api::customer.customer',
|
|
71
|
+
* (_parent, _args, ctx) => Boolean(ctx.state.user?.subscription)
|
|
72
|
+
* )
|
|
62
73
|
*/
|
|
63
74
|
export declare function registerGlobalAuthorize(matcher: FieldMatcher, authorize: ResolverFunction, options?: GlobalRegistrationOptions): void;
|
|
@@ -8,6 +8,10 @@ const globalAuthorizes = [];
|
|
|
8
8
|
* first). Matched when the schema is built, so registration order relative to fields does not matter;
|
|
9
9
|
* register before Strapi's bootstrap (i.e. at module level in a `graphql` folder).
|
|
10
10
|
*
|
|
11
|
+
* **Runs once per resolved field, not once per request.** A query that resolves 50 fields runs the
|
|
12
|
+
* middleware 50 times. Per-request concerns such as rate limiting or request logging belong in a
|
|
13
|
+
* Koa/Strapi middleware, not here.
|
|
14
|
+
*
|
|
11
15
|
* @param matcher - Decides per field; see {@link FieldMatcherInfo}.
|
|
12
16
|
* @param middleware - `(next) => resolver`, as in `.middleware()`.
|
|
13
17
|
* @param options - See {@link GlobalRegistrationOptions}.
|
|
@@ -23,13 +27,20 @@ exports.registerGlobalMiddleware = registerGlobalMiddleware;
|
|
|
23
27
|
* Register an authorization check for all fields that match. Global checks run before the field's own
|
|
24
28
|
* checks; all must return `true`. Matched when the schema is built, so registration order does not matter.
|
|
25
29
|
*
|
|
30
|
+
* **Runs once per resolved field, not once per request.** A query that resolves 50 fields runs the
|
|
31
|
+
* check 50 times, so a per-field check must stay cheap and free of per-request side effects. Rate
|
|
32
|
+
* limiting and similar per-request concerns belong in a Koa/Strapi middleware, not here.
|
|
33
|
+
*
|
|
26
34
|
* @param matcher - Decides per field; see {@link FieldMatcherInfo}.
|
|
27
35
|
* @param authorize - Return `true` to allow, `false` or an `Error` to deny.
|
|
28
36
|
* @param options - See {@link GlobalRegistrationOptions}.
|
|
29
37
|
*
|
|
30
38
|
* @example
|
|
31
|
-
* //
|
|
32
|
-
* registerGlobalAuthorize(
|
|
39
|
+
* // Require a paid subscription for every customer field
|
|
40
|
+
* registerGlobalAuthorize(
|
|
41
|
+
* ({ contentType }) => contentType === 'api::customer.customer',
|
|
42
|
+
* (_parent, _args, ctx) => Boolean(ctx.state.user?.subscription)
|
|
43
|
+
* )
|
|
33
44
|
*/
|
|
34
45
|
function registerGlobalAuthorize(matcher, authorize, options = {}) {
|
|
35
46
|
globalAuthorizes.push({ matcher, value: authorize, options });
|
|
@@ -34,13 +34,16 @@ export type RelationFieldSpec = {
|
|
|
34
34
|
resolverConfigPaths: string[];
|
|
35
35
|
};
|
|
36
36
|
/**
|
|
37
|
-
* Describes the GraphQL field(s) Strapi itself generates for a
|
|
37
|
+
* Describes the GraphQL field(s) Strapi itself generates for a relation or media attribute, so we can
|
|
38
38
|
* (re)define them with exactly the same type, arguments, resolver and permission scope.
|
|
39
39
|
*
|
|
40
40
|
* - v4: to-one → `XEntityResponse`, to-many → `XRelationResponseCollection` (with filters/pagination args).
|
|
41
41
|
* - v5: to-one → `X` (unwrapped), to-many → `[X]!` (unwrapped `nodes`) **and** `<field>_connection`
|
|
42
42
|
* returning `XRelationResponseCollection`.
|
|
43
43
|
*
|
|
44
|
+
* Media (`type: 'media'`) resolves against `plugin::upload.file`; Strapi generates the same fields as
|
|
45
|
+
* for a relation, with `multiple` deciding the to-one/to-many shape.
|
|
46
|
+
*
|
|
44
47
|
* All variants use Strapi's own `buildAssociationResolver`, so query validation, output sanitizing and
|
|
45
48
|
* draft/publish handling stay identical to the generated field.
|
|
46
49
|
*
|
|
@@ -48,7 +51,7 @@ export type RelationFieldSpec = {
|
|
|
48
51
|
*
|
|
49
52
|
* @param resolveOverride - Replaces Strapi's association resolver. It must return Strapi's response
|
|
50
53
|
* wrapper (`{ value }` / `{ nodes }`); v5 unwrapping is applied on top.
|
|
51
|
-
* @throws Error when the content type is unknown or the attribute is not a
|
|
54
|
+
* @throws Error when the content type is unknown or the attribute is not a relation or media attribute.
|
|
52
55
|
*/
|
|
53
56
|
export declare const relationFieldSpecs: (contentTypeUID: string, attributeName: string, resolveOverride?: RelationFieldSpec['resolve']) => RelationFieldSpec[];
|
|
54
57
|
/**
|
|
@@ -56,7 +59,9 @@ export declare const relationFieldSpecs: (contentTypeUID: string, attributeName:
|
|
|
56
59
|
* exactly like the generated relation fields do. Pass it to an extension's `resolversConfig` when you
|
|
57
60
|
* define relation fields yourself.
|
|
58
61
|
*
|
|
59
|
-
*
|
|
62
|
+
* Media fields get no scope, because Strapi does not attach one to the media fields it generates.
|
|
63
|
+
*
|
|
64
|
+
* @returns A map of `Type.field` paths to `{ auth: { scope } }`, or `{}` for media.
|
|
60
65
|
*/
|
|
61
66
|
export declare const relationResolversConfig: (contentTypeUID: string, attributeName: string) => {
|
|
62
67
|
[k: string]: {
|