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 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
- For to-one relations you can pass `{ batch: true }` to load the relation of all parents in the request with a
68
- single query (see [Data loading](#-data-loading)).
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 (raw rows, sanitize before returning them)
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. For draft &
117
- publish content types they load the published version unless `status: 'draft'` is passed.
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
- registerGlobalAuthorize(() => true, rateLimit, { includeNonBuilderFields: true });
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
- * Resolver for `.relation(..., { batch: true })`: loads the related entity of all parents in the request
486
- * with one query, sanitizes it like Strapi does and returns the shape the relation field expects.
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
- if (!attr || attr.type !== 'relation') {
492
- throw new utils_1.errors.ApplicationError(`"${contentType}.${attribute}" is not a relation`);
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
- if (String(attr.relation).endsWith('Many')) {
495
- throw new utils_1.errors.ApplicationError(`relation(..., { batch: true }) is only supported for to-one relations ("${contentType}.${attribute}" is to-many)`);
496
- }
497
- // Returns Strapi's response wrapper (`{ value, info }`), like `buildAssociationResolver`; the relation
498
- // field spec unwraps it on Strapi v5.
499
- const { toEntityResponse } = strapi.plugin('graphql').service('format').returnTypes;
500
- return async (parent, _args, context) => {
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
- const sanitized = related ? await (0, strapi_compat_1.sanitizeOutput)(related, attr.target, context) : null;
503
- return toEntityResponse(sanitized, { args: {}, resourceUID: attr.target });
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 a to-one relation with the per-request relation loader: one query for all parents in the
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
- * Not supported for to-many relations, because those take filter/pagination arguments per parent.
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 type, filter/pagination
138
- * arguments, resolver (query validation, output sanitizing, draft & publish handling) and `<target>.find`
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, or `batch` is used on
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 (including private fields): sanitize before returning them to clients,
64
- * see {@link sanitizeOutput}.
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), as raw database rows.
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
- * @throws Error when `attribute` is not a relation of `uid`.
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
- Object.defineProperty(exports, "__esModule", { value: true });
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}:${JSON.stringify(options)}`, () => createLoader(async (ids) => {
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
- return ids.map((id) => { var _a; return (_a = byId.get(String(id))) !== null && _a !== void 0 ? _a : null; });
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}:${JSON.stringify(options)}`, () => createLoader(async (ids) => {
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
- if (!attr || attr.type !== 'relation') {
142
- throw new Error(`[buro26-strapi-graphql] "${uid}.${attribute}" is not a relation`);
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
- const isToMany = String(attr.relation).endsWith('Many');
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
- return ids.map((id) => { var _a; return (_a = byId.get(String(id))) !== null && _a !== void 0 ? _a : (isToMany ? [] : null); });
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
- * // Rate limit every field, including Strapi's generated ones
61
- * registerGlobalAuthorize(() => true, rateLimit, { includeNonBuilderFields: true })
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
- * // Rate limit every field, including Strapi's generated ones
32
- * registerGlobalAuthorize(() => true, rateLimit, { includeNonBuilderFields: true })
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 regular (non-morph) relation, so we can
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 regular relation.
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
- * @returns A map of `Type.field` paths to `{ auth: { scope } }`.
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]: {