buro26-strapi-graphql 0.1.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.
Files changed (44) hide show
  1. package/README.md +173 -16
  2. package/dist/server/index.d.ts +1 -1
  3. package/dist/server/nexus/plugins/field-authorize.plugin.d.ts +21 -0
  4. package/dist/server/nexus/plugins/field-authorize.plugin.js +68 -35
  5. package/dist/server/nexus/plugins/middleware.plugin.d.ts +10 -2
  6. package/dist/server/nexus/plugins/middleware.plugin.js +20 -33
  7. package/dist/server/services/file-service.d.ts +7 -6
  8. package/dist/server/services/file-service.js +36 -36
  9. package/dist/server/services/index.d.ts +1 -1
  10. package/dist/server/types.d.ts +18 -3
  11. package/dist/server/utils/collection-type.d.ts +13 -2
  12. package/dist/server/utils/collection-type.js +13 -2
  13. package/dist/server/utils/field-builder/builder.d.ts +13 -4
  14. package/dist/server/utils/field-builder/builder.js +214 -119
  15. package/dist/server/utils/field-builder/helpers.d.ts +21 -6
  16. package/dist/server/utils/field-builder/helpers.js +26 -4
  17. package/dist/server/utils/field-builder/types.d.ts +297 -76
  18. package/dist/server/utils/graphql-extension.d.ts +24 -3
  19. package/dist/server/utils/graphql-extension.js +73 -7
  20. package/dist/server/utils/index.d.ts +2 -0
  21. package/dist/server/utils/index.js +11 -0
  22. package/dist/server/utils/loaders.d.ts +110 -0
  23. package/dist/server/utils/loaders.js +222 -0
  24. package/dist/server/utils/misc.d.ts +6 -0
  25. package/dist/server/utils/misc.js +6 -0
  26. package/dist/server/utils/override.d.ts +32 -0
  27. package/dist/server/utils/override.js +54 -26
  28. package/dist/server/utils/registry/field-protection.d.ts +43 -0
  29. package/dist/server/utils/registry/field-protection.js +65 -0
  30. package/dist/server/utils/registry/global-registry.d.ts +69 -14
  31. package/dist/server/utils/registry/global-registry.js +53 -7
  32. package/dist/server/utils/registry/index.d.ts +2 -0
  33. package/dist/server/utils/registry/index.js +2 -0
  34. package/dist/server/utils/registry/resolve-field.d.ts +1 -0
  35. package/dist/server/utils/registry/resolve-field.js +67 -0
  36. package/dist/server/utils/shadow-crud.d.ts +28 -1
  37. package/dist/server/utils/shadow-crud.js +32 -1
  38. package/dist/server/utils/single-type.d.ts +30 -1
  39. package/dist/server/utils/single-type.js +25 -0
  40. package/dist/server/utils/strapi-compat.d.ts +90 -0
  41. package/dist/server/utils/strapi-compat.js +203 -0
  42. package/dist/server/utils/types.d.ts +25 -1
  43. package/dist/tsconfig.server.tsbuildinfo +1 -1
  44. package/package.json +3 -2
package/README.md CHANGED
@@ -30,13 +30,145 @@ This fluent, type-safe builder gives you the best DX, composability, and testabi
30
30
  import { createGraphQLFieldBuilder } from 'buro26-strapi-graphql';
31
31
  import { nonNull, stringArg } from 'nexus';
32
32
 
33
- export default createGraphQLFieldBuilder('Query')
33
+ export default createGraphQLFieldBuilder()
34
+ .rootType('Query')
34
35
  .fieldName('helloWorld')
35
36
  .args({ name: nonNull(stringArg()) })
36
37
  .outputType(nonNull('String'))
37
38
  .description('Returns a personalized greeting')
38
- .resolver(async (parent, args) => `Hello World ${args.name}`)
39
- .resolverConfig({ auth: false });
39
+ .withAuth(false)
40
+ .resolver(async (parent, args) => `Hello World ${args.name}`);
41
+ ```
42
+
43
+ > **Canonical style:** `createGraphQLFieldBuilder().rootType(...)`, `.withAuth(...)` for authentication and
44
+ > `.authorize(...)` for authorization. `createGraphQLFieldBuilder('Query')` and `.resolverConfig({ auth })`
45
+ > still work but are not used in new code.
46
+
47
+ ---
48
+
49
+ ## 🔗 Relations
50
+
51
+ Use `.relation()` to define (or protect) a relation field. It produces exactly the field Strapi generates:
52
+ the same output type, filter/pagination arguments, resolver (with query validation and output sanitizing)
53
+ and, for relations, the `<target>.find` permission scope, on **Strapi v4 and v5**.
54
+
55
+ ```typescript
56
+ export default createGraphQLFieldBuilder()
57
+ .rootType('Order')
58
+ .relation('api::order.order', 'customer')
59
+ .authorize(isOrderCustomer);
60
+ ```
61
+
62
+ | | Strapi v4 | Strapi v5 |
63
+ |---|---|---|
64
+ | to-one | `XEntityResponse` | `X` |
65
+ | to-many | `XRelationResponseCollection` | `[X]!` **and** `<field>_connection` (both get the same auth/middleware) |
66
+
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.
83
+
84
+ ---
85
+
86
+ ## 🛡️ Protecting Strapi's Generated Fields
87
+
88
+ `protectFields()` adds authorization and/or middleware to fields Strapi generates for a content type —
89
+ scalars, enums, media, components and relations — **without redefining them**. Strapi's types, arguments and
90
+ resolvers stay untouched, so it behaves the same on v4 and v5 (for v5 to-many relations the
91
+ `<field>_connection` field is protected too).
92
+
93
+ ```typescript
94
+ // e.g. src/api/customer/graphql/_protect.ts
95
+ import { protectFields } from 'buro26-strapi-graphql';
96
+
97
+ protectFields<Customer>('api::customer.customer', ['street', 'postalCode', 'iban', 'orders'])
98
+ .authorize((customer, _args, ctx) => isSelf(customer, ctx.state.user))
99
+ .middleware(auditLog);
100
+ ```
101
+
102
+ The checks are attached by the package's Nexus plugins when the schema is built, so the file only needs to be
103
+ loaded from a `graphql` folder; it does not have to export anything.
104
+
105
+ > **Non-null fields:** a denied non-null field makes GraphQL null its parent object (standard error propagation).
106
+ > Strapi generates required attributes and v5 to-many relations (`[X]!`) as non-null, so protecting those hides the
107
+ > whole parent for users without access. A warning is logged when the schema is built. Prefer this over `overrideField` when you
108
+ only want to restrict access.
109
+
110
+ ---
111
+
112
+ ## ⚡ Data Loading
113
+
114
+ `loaders(ctx)` gives every request its own batching cache (DataLoader-style, no extra dependency). Loads from the
115
+ same tick are combined into one query; results are cached until the request ends.
116
+
117
+ ```typescript
118
+ import { loaders, sanitizeOutput } from 'buro26-strapi-graphql';
119
+
120
+ // One findMany ... where id IN (...) for all parents in the request
121
+ const order = await loaders(ctx).entity(ORDER, { populate: { customer: true } }).load(parent.id);
122
+
123
+ // The relation of many parents in one query. Raw rows: sanitize before returning them.
124
+ const customer = await loaders(ctx).relation(ORDER, 'customer').load(parent.id);
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
+
132
+ // Your own batch function, e.g. for an external API
133
+ const profile = await loaders(ctx).custom('profile', (ids: readonly number[]) => fetchProfiles(ids)).load(parent.id);
134
+ ```
135
+
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.
148
+
149
+ ---
150
+
151
+ ## 🏷️ Typing Your Project
152
+
153
+ Register your user type and entity types once, so `context.state.user` and `parent` are typed everywhere:
154
+
155
+ ```typescript
156
+ declare module 'buro26-strapi-graphql' {
157
+ interface Register {
158
+ user: User;
159
+ types: { Customer: Customer; Order: Order };
160
+ }
161
+ }
162
+
163
+ createGraphQLFieldBuilder()
164
+ .rootType('Customer') // parent is now typed as Customer
165
+ .fieldName('fullName')
166
+ .outputType('String')
167
+ .resolver((customer) => `${customer?.firstName} ${customer?.lastName}`);
168
+ ```
169
+
170
+ For registered entity types, `parent` is `Partial<Entity>` with `id` required: Strapi always passes the entity,
171
+ but relations are only present when populated. Unregistered `Query`/`Mutation` fields keep `parent: any`.
40
172
  ```
41
173
 
42
174
  ---
@@ -141,10 +273,24 @@ t.field('myField', {
141
273
  You can register **global middleware** and **global authorization** that apply to multiple fields based on type, field
142
274
  name, or tags.
143
275
 
144
- **Important:**
145
- > **Global middleware and authorization must be registered _before_ any fields that consume them are built or
146
- registered.**
147
- > This ensures that all relevant fields will pick up the global logic.
276
+ Global middleware and authorization are matched when the GraphQL schema is built, so they apply regardless of
277
+ the order in which files are loaded. The matcher receives `{ type, fieldName, tags, contentType, isBuilderField }`.
278
+
279
+ By default globals only apply to fields created with the field builder. Pass `{ includeNonBuilderFields: true }`
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:
285
+
286
+ ```typescript
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
+ };
293
+ ```
148
294
 
149
295
  **Example:**
150
296
 
@@ -166,6 +312,12 @@ registerGlobalAuthorize(
166
312
  async (parent, args, ctx) => ctx.state.user?.isAdmin
167
313
  );
168
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
+
169
321
  // Now define fields that will use these global middlewares/authorizations
170
322
  export default createGraphQLFieldBuilder('Query')
171
323
  .fieldName('deleteUser')
@@ -174,11 +326,9 @@ export default createGraphQLFieldBuilder('Query')
174
326
  .resolver(() => true);
175
327
  ```
176
328
 
177
- **Best Practice:**
178
-
179
- - Always register global middleware and authorization at the top of your entry file (before any field or extension
180
- exports).
181
- - This guarantees that all fields will be able to consume the global logic.
329
+ **Order of execution per field:** authentication (`withAuth(true)`) → global authorizes → the field's own
330
+ `authorize` → `protectFields` authorizes → global middlewares → the field's own middlewares → `protectFields`
331
+ middlewares → resolver (argument validation, hooks, tracing).
182
332
 
183
333
  ---
184
334
 
@@ -187,14 +337,14 @@ export default createGraphQLFieldBuilder('Query')
187
337
  - Use the **builder’s `.authorize()` and `.middleware()`** methods for most use-cases—they automatically set the correct
188
338
  `extensions` properties.
189
339
  - Use the `extensions` property directly for advanced or manual Nexus usage.
190
- - Register **global middleware and authorization before** any fields that should use them.
340
+ - Use **`protectFields()`** to restrict Strapi's generated fields and **`.relation()`** for relation fields.
191
341
  - Use **tags** to target groups of fields for global logic.
192
342
 
193
343
  ---
194
344
 
195
345
  ### **Validation, Tracing, and Testing**
196
346
 
197
- **Argument validation with Zod:**
347
+ **Argument validation with Zod** (also types the resolver's `args` as the parsed schema output):
198
348
 
199
349
  ```typescript
200
350
  import { z } from 'zod';
@@ -222,7 +372,8 @@ export default createGraphQLFieldBuilder('Query')
222
372
  });
223
373
  ```
224
374
 
225
- **Testing in isolation:**
375
+ **Testing in isolation** (runs the same pipeline as in production: global and field authorization, `withAuth`,
376
+ middlewares, argument validation and hooks; pass `skipAuth` / `skipMiddleware` to leave parts out):
226
377
 
227
378
  ```typescript
228
379
  const field = createGraphQLFieldBuilder('Query')
@@ -316,7 +467,11 @@ export default createGraphQLExtension()
316
467
 
317
468
  ## 🧩 Overriding and Extending Fields
318
469
 
319
- You can override or extend fields in existing types using the `overrideField` helper:
470
+ > To only restrict access to generated fields, use [`protectFields()`](#️-protecting-strapis-generated-fields);
471
+ > for relations, use [`.relation()`](#-relations). Use `overrideField` when you need to replace a field's resolver.
472
+
473
+ You can override or extend fields in existing types using the `overrideField` helper. Pass authorization via
474
+ `authorize` (or `extensions.authorize`); relation fields are generated for both Strapi v4 and v5:
320
475
 
321
476
  ```typescript
322
477
  import { createGraphQLExtension, overrideField } from 'buro26-strapi-graphql';
@@ -461,6 +616,8 @@ To run tests:
461
616
  bun test
462
617
  ```
463
618
 
619
+ Tests live in `test/` and run against real Nexus/GraphQL schemas with a minimal `strapi` mock.
620
+
464
621
  ---
465
622
 
466
623
  ## 🤝 Contributing
@@ -27,7 +27,7 @@ declare const _default: {
27
27
  fileService: ({ strapi }: {
28
28
  strapi: Required<import("@strapi/types").Strapi>;
29
29
  }) => {
30
- getAllFilesInGraphQlDirectory(directoryPath: string, collectedExtensions?: GraphQLExtension[]): Promise<GraphQLExtension[]>;
30
+ getAllFilesInGraphQlDirectory(directoryPath: string): Promise<GraphQLExtension[]>;
31
31
  };
32
32
  graphQlService: ({ strapi }: {
33
33
  strapi: Required<import("@strapi/types").Strapi>;
@@ -8,8 +8,29 @@ export interface FieldAuthorizePluginErrorConfig {
8
8
  ctx: GetGen<'context'>;
9
9
  info: GraphQLResolveInfo;
10
10
  }
11
+ /**
12
+ * Options for {@link nexusFieldAuthorizePlugin}.
13
+ */
11
14
  export interface FieldAuthorizePluginConfig {
15
+ /**
16
+ * Turn a denied authorization into the error sent to the client.
17
+ * @default {@link defaultFormatError}
18
+ */
12
19
  formatError?: (authConfig: FieldAuthorizePluginErrorConfig) => Error;
13
20
  }
21
+ /**
22
+ * Default error for denied authorization: a Strapi `ForbiddenError` with the original message and
23
+ * trace details (type, field, user id, args, timestamp) in `details.trace`.
24
+ */
14
25
  export declare const defaultFormatError: ({ error, root, args, ctx, info }: FieldAuthorizePluginErrorConfig) => Error;
26
+ /**
27
+ * Field-level authentication and authorization.
28
+ *
29
+ * Applies to every field in the schema (including the ones Strapi generates) and combines:
30
+ * `withAuth(true)` of builder fields, global authorizes, the field's own `authorize` (in `extensions` or
31
+ * as top-level field option) and `protectFields()` registrations.
32
+ *
33
+ * Registered automatically by this Strapi plugin; only add it yourself when building a schema outside
34
+ * Strapi (e.g. in tests).
35
+ */
15
36
  export declare const nexusFieldAuthorizePlugin: (authConfig?: FieldAuthorizePluginConfig) => import("nexus/dist/plugin").NexusPlugin;
@@ -1,9 +1,11 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.nexusFieldAuthorizePlugin = exports.defaultFormatError = void 0;
3
+ exports.nexusFieldAuthorizePlugin = exports.runAuthorization = exports.defaultFormatError = void 0;
4
4
  const nexus_1 = require("nexus");
5
5
  const utils_1 = require("nexus/dist/utils");
6
6
  const utils_2 = require("@strapi/utils");
7
+ const graphql_1 = require("graphql");
8
+ const resolve_field_1 = require("../../utils/registry/resolve-field");
7
9
  const fieldDefTypes = (0, utils_1.printedGenTyping)({
8
10
  optional: true,
9
11
  name: 'authorize',
@@ -17,6 +19,10 @@ const fieldDefTypes = (0, utils_1.printedGenTyping)({
17
19
  `,
18
20
  type: 'FieldAuthorizeResolver<TypeName, FieldName>',
19
21
  });
22
+ /**
23
+ * Default error for denied authorization: a Strapi `ForbiddenError` with the original message and
24
+ * trace details (type, field, user id, args, timestamp) in `details.trace`.
25
+ */
20
26
  const defaultFormatError = ({ error, root, args, ctx, info }) => {
21
27
  var _a, _b;
22
28
  // Add rich tracing info
@@ -32,9 +38,15 @@ const defaultFormatError = ({ error, root, args, ctx, info }) => {
32
38
  return new utils_2.errors.ForbiddenError((error === null || error === void 0 ? void 0 : error.message) || 'Not authorized', { trace });
33
39
  };
34
40
  exports.defaultFormatError = defaultFormatError;
35
- const nexusFieldAuthorizePlugin = (authConfig = {}) => {
36
- const { formatError = exports.defaultFormatError } = authConfig;
37
- const ensureError = (root, args, ctx, info) => (error) => {
41
+ /**
42
+ * Run the authentication + authorization part of a field pipeline.
43
+ * Resolves when allowed, rejects with the (formatted) error otherwise.
44
+ *
45
+ * @internal Used by the authorize plugin and `.test()`.
46
+ */
47
+ const runAuthorization = async (pipeline, root, args, ctx, info, formatError = exports.defaultFormatError) => {
48
+ var _a, _b;
49
+ const fail = (error) => {
38
50
  const finalErr = formatError({ error, root, args, ctx, info });
39
51
  if (finalErr instanceof Error) {
40
52
  throw finalErr;
@@ -42,46 +54,67 @@ const nexusFieldAuthorizePlugin = (authConfig = {}) => {
42
54
  console.error(`Non-Error value ${finalErr} returned from custom formatError in authorize plugin`);
43
55
  throw new utils_2.errors.ForbiddenError('Not authorized');
44
56
  };
57
+ if (pipeline.requireAuth && !((_a = ctx === null || ctx === void 0 ? void 0 : ctx.state) === null || _a === void 0 ? void 0 : _a.user)) {
58
+ // Same error as before, but now evaluated before any authorize function runs
59
+ throw new utils_2.errors.ForbiddenError('Not authenticated');
60
+ }
61
+ for (const authorize of pipeline.authorizes) {
62
+ let authResult;
63
+ try {
64
+ authResult = await authorize(root, args, ctx, info);
65
+ }
66
+ catch (e) {
67
+ fail(e instanceof Error ? e : new utils_2.errors.ForbiddenError(String(e)));
68
+ }
69
+ if (authResult === true)
70
+ continue;
71
+ if (authResult instanceof Error)
72
+ fail(authResult);
73
+ if (authResult === false)
74
+ fail(new utils_2.errors.ForbiddenError('Not authorized'));
75
+ fail(new utils_2.errors.ForbiddenError(`Authorize for ${(_b = info === null || info === void 0 ? void 0 : info.parentType) === null || _b === void 0 ? void 0 : _b.name}.${info === null || info === void 0 ? void 0 : info.fieldName} Expected a boolean or Error, saw ${authResult}`));
76
+ }
77
+ };
78
+ exports.runAuthorization = runAuthorization;
79
+ /**
80
+ * Field-level authentication and authorization.
81
+ *
82
+ * Applies to every field in the schema (including the ones Strapi generates) and combines:
83
+ * `withAuth(true)` of builder fields, global authorizes, the field's own `authorize` (in `extensions` or
84
+ * as top-level field option) and `protectFields()` registrations.
85
+ *
86
+ * Registered automatically by this Strapi plugin; only add it yourself when building a schema outside
87
+ * Strapi (e.g. in tests).
88
+ */
89
+ const nexusFieldAuthorizePlugin = (authConfig = {}) => {
90
+ const { formatError = exports.defaultFormatError } = authConfig;
45
91
  return (0, nexus_1.plugin)({
46
92
  name: 'CustomFieldAuthorize',
47
93
  description: 'Custom field-level authorization for Strapi GraphQL, with tracing.',
48
94
  fieldDefTypes: fieldDefTypes,
49
95
  onCreateFieldResolver(config) {
50
- var _a;
96
+ var _a, _b, _c;
51
97
  // @ts-ignore
52
- const authorize = (_a = config.fieldConfig.extensions) === null || _a === void 0 ? void 0 : _a.authorize;
53
- if (authorize == null) {
54
- return;
98
+ const extensions = config.fieldConfig.extensions;
99
+ if ((extensions === null || extensions === void 0 ? void 0 : extensions.authorize) != null && typeof extensions.authorize !== 'function') {
100
+ console.error(new Error(`The authorize property provided to ${config.fieldConfig.name} with type ${config.fieldConfig.type} should be a function, saw ${typeof extensions.authorize}`));
101
+ // Fail closed: a broken authorize must not silently disable the other checks on this field
102
+ return async function () {
103
+ throw new utils_2.errors.ForbiddenError('Not authorized');
104
+ };
55
105
  }
56
- if (typeof authorize !== 'function') {
57
- console.error(new Error(`The authorize property provided to ${config.fieldConfig.name} with type ${config.fieldConfig.type} should be a function, saw ${typeof authorize}`));
106
+ const pipeline = (0, resolve_field_1.resolveFieldPipeline)(config.parentTypeConfig.name, config.fieldConfig.name, extensions);
107
+ if (!pipeline.requireAuth && pipeline.authorizes.length === 0) {
58
108
  return;
59
109
  }
60
- return function (root, args, ctx, info, next) {
61
- console.log(`Running authorize for ${info.parentType.name}.${info.fieldName}`);
62
- let toComplete;
63
- try {
64
- toComplete = authorize(root, args, ctx, info);
65
- }
66
- catch (e) {
67
- toComplete = Promise.reject(e);
68
- }
69
- return nexus_1.plugin.completeValue(toComplete, (authResult) => {
70
- if (authResult === true) {
71
- return next(root, args, ctx, info);
72
- }
73
- const finalFormatError = ensureError(root, args, ctx, info);
74
- if (authResult instanceof Error) {
75
- finalFormatError(authResult);
76
- }
77
- if (authResult === false) {
78
- finalFormatError(new utils_2.errors.ForbiddenError('Not authorized'));
79
- }
80
- const { fieldName, parentType: { name: parentTypeName } } = info;
81
- finalFormatError(new utils_2.errors.ForbiddenError(`Authorize for ${parentTypeName}.${fieldName} Expected a boolean or Error, saw ${authResult}`));
82
- }, (err) => {
83
- ensureError(root, args, ctx, info)(err);
84
- });
110
+ if (pipeline.isProtected && (0, graphql_1.isNonNullType)(config.fieldConfig.type)) {
111
+ const warn = (_c = (_b = (_a = globalThis.strapi) === null || _a === void 0 ? void 0 : _a.log) === null || _b === void 0 ? void 0 : _b.warn) !== null && _c !== void 0 ? _c : console.warn;
112
+ warn(`[buro26-strapi-graphql] protectFields: "${config.parentTypeConfig.name}.${config.fieldConfig.name}" is non-null; ` +
113
+ `when access is denied GraphQL nulls the parent object instead of only this field.`);
114
+ }
115
+ return async function (root, args, ctx, info, next) {
116
+ await (0, exports.runAuthorization)(pipeline, root, args, ctx, info, formatError);
117
+ return next(root, args, ctx, info);
85
118
  };
86
119
  }
87
120
  });
@@ -9,9 +9,17 @@ export type Resolver = <Parent extends any, Args extends any, Context = ResolveC
9
9
  * Middleware is a function that takes a resolver and returns a new resolver.
10
10
  */
11
11
  export type Middleware<TParent = any, TArgs = any> = (resolver: ResolverFunction<TParent, TArgs>) => ResolverFunction<TParent, TArgs>;
12
+ /**
13
+ * Field-level middlewares: global middlewares, the field's own `middlewares` (in `extensions` or as
14
+ * top-level field option) and `protectFields()` middlewares, outermost first.
15
+ *
16
+ * Registered automatically by this Strapi plugin; only add it yourself when building a schema outside
17
+ * Strapi (e.g. in tests).
18
+ */
12
19
  export declare const nexusMiddlewarePlugin: () => import("nexus/dist/plugin").NexusPlugin;
13
20
  /**
14
- * Logging Middleware
15
- * Logs the execution of the resolver.
21
+ * Example middleware that logs every resolve, including the result.
22
+ *
23
+ * Development only: it logs resolved values, which may contain personal data.
16
24
  */
17
25
  export declare const loggingMiddleware: Middleware;
@@ -3,19 +3,23 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.loggingMiddleware = exports.nexusMiddlewarePlugin = void 0;
4
4
  const nexus_1 = require("nexus");
5
5
  const utils_1 = require("nexus/dist/utils");
6
+ const resolve_field_1 = require("../../utils/registry/resolve-field");
6
7
  const fieldDefTypes = (0, utils_1.printedGenTyping)({
7
8
  optional: true,
8
9
  name: 'middlewares',
9
10
  description: `
10
- Authorization for an individual field. Returning "true"
11
- or "Promise<true>" means the field can be accessed.
12
- Returning "false" or "Promise<false>" will respond
13
- with a "Not Authorized" error for the field.
14
- Returning or throwing an error will also prevent the
15
- resolver from executing.
11
+ Middlewares for an individual field. Each middleware receives the
12
+ next resolver and returns a new resolver.
16
13
  `,
17
14
  type: 'any'
18
15
  });
16
+ /**
17
+ * Field-level middlewares: global middlewares, the field's own `middlewares` (in `extensions` or as
18
+ * top-level field option) and `protectFields()` middlewares, outermost first.
19
+ *
20
+ * Registered automatically by this Strapi plugin; only add it yourself when building a schema outside
21
+ * Strapi (e.g. in tests).
22
+ */
19
23
  const nexusMiddlewarePlugin = () => (0, nexus_1.plugin)({
20
24
  name: 'NexusMiddlewarePlugin',
21
25
  description: 'The middleware plugin allows attaching middleware functions to individual fields.',
@@ -27,43 +31,26 @@ const nexusMiddlewarePlugin = () => (0, nexus_1.plugin)({
27
31
  * Hook into field resolver creation to apply middleware.
28
32
  */
29
33
  onCreateFieldResolver(config) {
30
- var _a;
31
34
  const { fieldConfig } = config;
35
+ const { middlewares } = (0, resolve_field_1.resolveFieldPipeline)(config.parentTypeConfig.name, fieldConfig.name,
32
36
  // @ts-ignore
33
- const middlewares = ((_a = fieldConfig.extensions) === null || _a === void 0 ? void 0 : _a.middlewares) || [];
37
+ fieldConfig.extensions);
34
38
  if (middlewares.length === 0) {
35
39
  return undefined; // No middleware to apply
36
40
  }
37
- // Convert Middleware[] to MiddlewareFn[]
38
- const middlewareFns = middlewares.map(middleware => {
39
- return async (source, args, context, info, next) => {
40
- const newResolver = middleware(next);
41
- return newResolver(source, args, context, info);
42
- };
43
- });
44
- // Compose the middleware functions into a single MiddlewareFn
45
- return async (source, args, context, info, next) => {
46
- let index = -1;
47
- const dispatch = async (i) => {
48
- if (i <= index)
49
- throw new Error('next() called multiple times');
50
- index = i;
51
- const middlewareFn = middlewareFns[i];
52
- if (middlewareFn) {
53
- return middlewareFn(source, args, context, info, () => dispatch(i + 1));
54
- }
55
- else {
56
- return next(source, args, context, info);
57
- }
58
- };
59
- return dispatch(0);
41
+ // Outermost first. Each middleware may call the next resolver with changed parent/args/context,
42
+ // which are passed on (same composition as `.test()`).
43
+ return (source, args, context, info, next) => {
44
+ const resolver = middlewares.reduceRight((inner, middleware) => middleware(inner), (s, a, c, i) => next(s, a, c, i));
45
+ return resolver(source, args, context, info);
60
46
  };
61
47
  }
62
48
  });
63
49
  exports.nexusMiddlewarePlugin = nexusMiddlewarePlugin;
64
50
  /**
65
- * Logging Middleware
66
- * Logs the execution of the resolver.
51
+ * Example middleware that logs every resolve, including the result.
52
+ *
53
+ * Development only: it logs resolved values, which may contain personal data.
67
54
  */
68
55
  const loggingMiddleware = (resolver) => {
69
56
  return async (parent, args, context, info) => {
@@ -3,13 +3,14 @@ declare const _default: ({ strapi }: {
3
3
  strapi: Strapi;
4
4
  }) => {
5
5
  /**
6
- * Fetch all the files in the graphql directories within the given directory path
7
- * Recursively searches for files in the graphql directory
8
- * Returns an array of GraphQLExtension objects
6
+ * Load all extensions from `graphql` folders below `directoryPath` (recursively).
9
7
  *
10
- * @param directoryPath
11
- * @param collectedExtensions
8
+ * Files are loaded in a stable order (sorted by name, depth-first), so the generated schema and the
9
+ * order of side effects such as `protectFields()` / `registerGlobalAuthorize()` calls are the same on
10
+ * every start.
11
+ *
12
+ * @param directoryPath - Usually `strapi.dirs.dist.src`.
12
13
  */
13
- getAllFilesInGraphQlDirectory(directoryPath: string, collectedExtensions?: GraphQLExtension[]): Promise<GraphQLExtension[]>;
14
+ getAllFilesInGraphQlDirectory(directoryPath: string): Promise<GraphQLExtension[]>;
14
15
  };
15
16
  export default _default;
@@ -25,50 +25,50 @@ var __importStar = (this && this.__importStar) || function (mod) {
25
25
  Object.defineProperty(exports, "__esModule", { value: true });
26
26
  const fs_1 = require("fs");
27
27
  const path = __importStar(require("path"));
28
+ const GRAPHQL_FOLDER = 'graphql';
29
+ const isInGraphQLFolder = (directoryPath) => directoryPath.endsWith(path.sep + GRAPHQL_FOLDER) || directoryPath.includes(path.sep + GRAPHQL_FOLDER + path.sep);
30
+ const isLoadableFile = (file) => file.endsWith('.js');
31
+ /**
32
+ * Turn a module's export into extensions. Builders (anything with `build()`) are built; arrays are flattened.
33
+ */
34
+ const toExtensions = (graphqlModule) => {
35
+ var _a;
36
+ const exported = (_a = graphqlModule === null || graphqlModule === void 0 ? void 0 : graphqlModule.default) !== null && _a !== void 0 ? _a : graphqlModule;
37
+ if (!exported)
38
+ return [];
39
+ const build = (item) => (item && typeof item.build === 'function' ? item.build() : item);
40
+ if (Array.isArray(exported)) {
41
+ return exported.map(build).filter(Boolean);
42
+ }
43
+ // Modules without a default export (e.g. one that only calls `protectFields`) yield an empty extension,
44
+ // which registers nothing.
45
+ return [build(exported)];
46
+ };
28
47
  exports.default = ({ strapi }) => ({
29
48
  /**
30
- * Fetch all the files in the graphql directories within the given directory path
31
- * Recursively searches for files in the graphql directory
32
- * Returns an array of GraphQLExtension objects
49
+ * Load all extensions from `graphql` folders below `directoryPath` (recursively).
33
50
  *
34
- * @param directoryPath
35
- * @param collectedExtensions
51
+ * Files are loaded in a stable order (sorted by name, depth-first), so the generated schema and the
52
+ * order of side effects such as `protectFields()` / `registerGlobalAuthorize()` calls are the same on
53
+ * every start.
54
+ *
55
+ * @param directoryPath - Usually `strapi.dirs.dist.src`.
36
56
  */
37
- async getAllFilesInGraphQlDirectory(directoryPath, collectedExtensions = []) {
38
- const fileEntries = await fs_1.promises.readdir(directoryPath);
39
- // Create an array of promises for all the files
40
- const filePromises = fileEntries.map(async (file) => {
57
+ async getAllFilesInGraphQlDirectory(directoryPath) {
58
+ const fileEntries = [...await fs_1.promises.readdir(directoryPath)].sort();
59
+ const collected = [];
60
+ for (const file of fileEntries) {
41
61
  const filePath = path.join(directoryPath, file);
42
62
  const fileStats = await fs_1.promises.stat(filePath);
43
63
  if (fileStats.isDirectory()) {
44
- // Recursively get files from the directory
45
- return this.getAllFilesInGraphQlDirectory(filePath, collectedExtensions);
46
- }
47
- const folderName = 'graphql';
48
- if (!directoryPath.endsWith(path.sep + folderName) &&
49
- !directoryPath.includes(path.sep + folderName + path.sep)) {
50
- return;
51
- }
52
- const graphqlModule = require(filePath.replace('.js', ''));
53
- if (!graphqlModule) {
54
- return;
55
- }
56
- // Support both ES module default export and CommonJS exports
57
- let extension = graphqlModule.default || graphqlModule;
58
- // If the extension is a builder (has a build function), call build() automatically
59
- if (extension && typeof extension.build === 'function') {
60
- extension = extension.build();
61
- }
62
- // If it's an array, flatten and push all
63
- if (Array.isArray(extension)) {
64
- collectedExtensions.push(...extension.map(ext => ext.build ? ext.build() : ext));
64
+ collected.push(...await this.getAllFilesInGraphQlDirectory(filePath));
65
+ continue;
65
66
  }
66
- else if (extension) {
67
- collectedExtensions.push(extension);
67
+ if (!isInGraphQLFolder(directoryPath) || !isLoadableFile(file)) {
68
+ continue;
68
69
  }
69
- });
70
- // Wait for all the promises to resolve
71
- await Promise.all(filePromises);
72
- return collectedExtensions;
70
+ collected.push(...toExtensions(require(filePath)));
71
+ }
72
+ return collected;
73
73
  }
74
74
  });