buro26-strapi-graphql 0.0.26 β†’ 0.2.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 (57) hide show
  1. package/README.md +458 -439
  2. package/dist/server/index.d.ts +14 -8
  3. package/dist/server/index.js +1 -0
  4. package/dist/server/nexus/index.d.ts +1 -0
  5. package/dist/server/nexus/index.js +17 -0
  6. package/dist/server/nexus/plugins/field-authorize.plugin.d.ts +36 -0
  7. package/dist/server/nexus/plugins/field-authorize.plugin.js +122 -0
  8. package/dist/server/nexus/plugins/index.d.ts +2 -0
  9. package/dist/server/nexus/plugins/index.js +18 -0
  10. package/dist/server/nexus/plugins/middleware.plugin.d.ts +25 -0
  11. package/dist/server/nexus/plugins/middleware.plugin.js +63 -0
  12. package/dist/server/register.js +6 -7
  13. package/dist/server/services/file-service.d.ts +7 -6
  14. package/dist/server/services/file-service.js +36 -30
  15. package/dist/server/services/index.d.ts +1 -1
  16. package/dist/server/types.d.ts +28 -13
  17. package/dist/server/utils/collection-type.d.ts +13 -2
  18. package/dist/server/utils/collection-type.js +13 -2
  19. package/dist/server/utils/field-builder/builder.d.ts +33 -0
  20. package/dist/server/utils/field-builder/builder.js +552 -0
  21. package/dist/server/utils/field-builder/helpers.d.ts +34 -0
  22. package/dist/server/utils/field-builder/helpers.js +78 -0
  23. package/dist/server/utils/field-builder/index.d.ts +3 -0
  24. package/dist/server/utils/field-builder/index.js +19 -0
  25. package/dist/server/utils/field-builder/types.d.ts +371 -0
  26. package/dist/server/utils/field-builder/types.js +2 -0
  27. package/dist/server/utils/graphql-extension.d.ts +29 -3
  28. package/dist/server/utils/graphql-extension.js +94 -26
  29. package/dist/server/utils/index.d.ts +3 -1
  30. package/dist/server/utils/index.js +12 -1
  31. package/dist/server/utils/loaders.d.ts +94 -0
  32. package/dist/server/utils/loaders.js +166 -0
  33. package/dist/server/utils/misc.d.ts +6 -0
  34. package/dist/server/utils/misc.js +6 -0
  35. package/dist/server/utils/override.d.ts +34 -1
  36. package/dist/server/utils/override.js +54 -26
  37. package/dist/server/utils/registry/field-protection.d.ts +43 -0
  38. package/dist/server/utils/registry/field-protection.js +65 -0
  39. package/dist/server/utils/registry/global-registry.d.ts +63 -0
  40. package/dist/server/utils/registry/global-registry.js +56 -0
  41. package/dist/server/utils/registry/index.d.ts +3 -0
  42. package/dist/server/utils/registry/index.js +19 -0
  43. package/dist/server/utils/registry/resolve-field.d.ts +1 -0
  44. package/dist/server/utils/registry/resolve-field.js +67 -0
  45. package/dist/server/utils/shadow-crud.d.ts +28 -1
  46. package/dist/server/utils/shadow-crud.js +32 -1
  47. package/dist/server/utils/single-type.d.ts +30 -1
  48. package/dist/server/utils/single-type.js +25 -0
  49. package/dist/server/utils/strapi-compat.d.ts +85 -0
  50. package/dist/server/utils/strapi-compat.js +194 -0
  51. package/dist/server/utils/types.d.ts +25 -1
  52. package/dist/tsconfig.server.tsbuildinfo +1 -1
  53. package/package.json +7 -4
  54. package/dist/server/utils/field-builder.d.ts +0 -31
  55. package/dist/server/utils/field-builder.js +0 -113
  56. package/dist/server/utils/middleware.d.ts +0 -17
  57. package/dist/server/utils/middleware.js +0 -77
package/README.md CHANGED
@@ -1,589 +1,608 @@
1
1
  # Strapi GraphQL Extension
2
2
 
3
- Extension and utilities for Strapi GraphQL API. This plugin adds additional features to the Strapi GraphQL plugin such
4
- as co-location of GraphQL schema and resolvers, field overriding, and field authorization.
3
+ **Buro26’s Strapi GraphQL Extension** supercharges your Strapi GraphQL API with:
5
4
 
6
- This let's you define your GraphQL schema and resolvers in the content-type directory, making it easier to manage and
7
- maintain your GraphQL API.
5
+ - Field-level authorization and middleware
6
+ - Co-location of schema and resolvers
7
+ - Fluent, type-safe builder APIs
8
+ - Powerful testing, validation, and tracing utilities
8
9
 
9
- This plugin is compatible with both Strapi v4 and Strapi v5.
10
+ > **Supports Strapi v4 and v5**
10
11
 
11
- ## Getting started
12
+ ---
13
+
14
+ ## πŸš€ Getting Started
12
15
 
13
16
  ```bash
14
17
  npm i buro26-strapi-graphql
15
18
  ```
16
19
 
17
- ## Usage
20
+ ---
21
+
22
+ ## πŸ—οΈ Recommended: The Field Builder Pattern
23
+
24
+ The **recommended way** to build GraphQL fields is with `createGraphQLFieldBuilder()`.
25
+ This fluent, type-safe builder gives you the best DX, composability, and testability.
18
26
 
19
- ### Configure Strapi GraphQL plugin
27
+ ### **Example: A Custom Query Field**
20
28
 
21
- To be able to use this plugin, you will first need to configure
22
- the [Strapi GraphQL plugin](https://docs.strapi.io/dev-docs/plugins/graphql).
29
+ ```typescript
30
+ import { createGraphQLFieldBuilder } from 'buro26-strapi-graphql';
31
+ import { nonNull, stringArg } from 'nexus';
32
+
33
+ export default createGraphQLFieldBuilder()
34
+ .rootType('Query')
35
+ .fieldName('helloWorld')
36
+ .args({ name: nonNull(stringArg()) })
37
+ .outputType(nonNull('String'))
38
+ .description('Returns a personalized greeting')
39
+ .withAuth(false)
40
+ .resolver(async (parent, args) => `Hello World ${args.name}`);
41
+ ```
23
42
 
24
- ### Enable plugin
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.
25
46
 
26
- Add plugin in `./config/plugins.[js/ts]` in your Strapi project.
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 `<target>.find` permission scope, on **Strapi v4 and v5**.
27
54
 
28
55
  ```typescript
29
- export default () => ({
30
- // ...
31
- 'burotwosix-graphql-plugin': {
32
- enabled: true
33
- }
34
- // ...
35
- })
56
+ export default createGraphQLFieldBuilder()
57
+ .rootType('Order')
58
+ .relation('api::order.order', 'customer')
59
+ .authorize(isOrderCustomer);
36
60
  ```
37
61
 
38
- ### Create a new GraphQL Extension
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) |
39
66
 
40
- To create a new GraphQL extension, create a folder named `graphql` in the content-type directory or within the
41
- extensions folder (in case of extending a plugin).
42
- Inside the `graphql` folder you can create files with any name. This plugin will scan the `graphql` folder register your
43
- extensions with Strapi.
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)).
44
69
 
45
- From each file create a default export that uses th `createGraphQLExtension` factory function. This function takes a
46
- function as an argument that will receive the Strapi instance and should return an object with the following methods:
70
+ ---
47
71
 
48
- | Method | Description |
49
- |-----------------|--------------------------------------------------|
50
- | init | Called when the extension is initialized |
51
- | types | Returns an array of GraphQL types |
52
- | typeDefs | Returns a string with the type definition in SDL |
53
- | resolvers | Returns an object with resolvers |
54
- | resolversConfig | Returns an object with resolver configuration |
72
+ ## πŸ›‘οΈ Protecting Strapi's Generated Fields
55
73
 
56
- The functions you can implement follow the pattern of the Strapi customization
57
- options: https://docs.strapi.io/dev-docs/plugins/graphql#customization. The return values here will be registered as-is
58
- with Strapi in the register hook.
74
+ `protectFields()` adds authorization and/or middleware to fields Strapi generates for a content type β€”
75
+ scalars, enums, media, components and relations β€” **without redefining them**. Strapi's types, arguments and
76
+ resolvers stay untouched, so it behaves the same on v4 and v5 (for v5 to-many relations the
77
+ `<field>_connection` field is protected too).
59
78
 
60
79
  ```typescript
61
- import { createGraphQLExtension } from 'buro26-strapi-graphql';
62
- import { extendType, nonNull, stringArg } from 'nexus';
80
+ // e.g. src/api/customer/graphql/_protect.ts
81
+ import { protectFields } from 'buro26-strapi-graphql';
63
82
 
64
- export default createGraphQLExtension(strapi => ({
83
+ protectFields<Customer>('api::customer.customer', ['street', 'postalCode', 'iban', 'orders'])
84
+ .authorize((customer, _args, ctx) => isSelf(customer, ctx.state.user))
85
+ .middleware(auditLog);
86
+ ```
65
87
 
66
- init() {
67
- // e.g. disable actions
68
- strapi
69
- .plugin('graphql')
70
- .service('extension')
71
- .shadowCRUD('api::my-type.my-type')
72
- .disableActions(['find', 'findOne', 'create', 'update', 'delete']);
73
- },
88
+ The checks are attached by the package's Nexus plugins when the schema is built, so the file only needs to be
89
+ loaded from a `graphql` folder; it does not have to export anything.
74
90
 
75
- typeDefs() {
76
- return `
77
- type HelloWorldResponse {
78
- greeting: String!
79
- }
80
- `;
81
- },
91
+ > **Non-null fields:** a denied non-null field makes GraphQL null its parent object (standard error propagation).
92
+ > Strapi generates required attributes and v5 to-many relations (`[X]!`) as non-null, so protecting those hides the
93
+ > whole parent for users without access. A warning is logged when the schema is built. Prefer this over `overrideField` when you
94
+ only want to restrict access.
82
95
 
83
- types() {
84
- return [
85
- // Extend an existing type
86
- extendType({
87
- type: 'MyType',
88
- definition(t) {
89
- t.field('foo', {
90
- type: 'Boolean',
91
- resolve: async (root) => {
92
- return false;
93
- }
94
- });
95
- t.field('bar', {
96
- type: 'Boolean',
97
- resolve: async (root) => {
98
- return false;
99
- }
100
- });
101
- }
102
- }),
96
+ ---
103
97
 
104
- // Create a new query
105
- extendType({
106
- type: 'Query',
107
- definition(t) {
108
- t.field('helloWorld', {
109
- type: 'HelloWorldResponse',
110
- args: {
111
- greeting: nonNull(stringArg())
112
- },
113
- // either resolve here or add it in resolvers method
114
- resolve: async (root, args) => {
115
- // resolve logic
116
- }
117
- });
118
- }
119
- })
120
- ];
121
- },
98
+ ## ⚑ Data Loading
122
99
 
123
- resolvers() {
124
- return {
125
- Query: {
126
- // Override an existing query
127
- helloWorld: async (root, args) => {
128
- // resolve logic
129
- }
130
- },
131
- Mutation: {
132
- // Override an existing mutation
133
- updateMyType: async (root, args) => {
134
- // resolve logic
135
- }
136
- }
137
- };
138
- },
100
+ `loaders(ctx)` gives every request its own batching cache (DataLoader-style, no extra dependency). Loads from the
101
+ same tick are combined into one query; results are cached until the request ends.
139
102
 
140
- resolversConfig() {
141
- return {
142
- 'Query.helloWorld': {
143
- policies: [
144
- async ({ state, args }, { strapi }) => {
145
- // Validate request
146
- }
147
- ],
148
- middlewares: [
149
- async (next, root, args, context, info) => {
150
- // Do something here
151
-
152
- return next(root, args, context, info);
153
- }
154
- ]
155
- }
156
- };
157
- }
158
- }));
103
+ ```typescript
104
+ import { loaders, sanitizeOutput } from 'buro26-strapi-graphql';
105
+
106
+ // One findMany ... where id IN (...) for all parents in the request
107
+ const order = await loaders(ctx).entity(ORDER, { populate: { customer: true } }).load(parent.id);
108
+
109
+ // The relation of many parents in one query (raw rows, sanitize before returning them)
110
+ const customer = await loaders(ctx).relation(ORDER, 'customer').load(parent.id);
111
+
112
+ // Your own batch function, e.g. for an external API
113
+ const profile = await loaders(ctx).custom('profile', (ids: readonly number[]) => fetchProfiles(ids)).load(parent.id);
159
114
  ```
160
115
 
161
- ### Fluent Builder Pattern
162
- The plugin also supports a builder pattern so that you can configure your extension using a fluent API. This is achieved by calling createGraphQLExtension() with no arguments. For example, you can write:
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.
118
+
119
+ ---
120
+
121
+ ## 🏷️ Typing Your Project
122
+
123
+ Register your user type and entity types once, so `context.state.user` and `parent` are typed everywhere:
163
124
 
164
125
  ```typescript
165
- export default createGraphQLExtension()
166
- .init(() => {
167
- // Initialization logic here...
168
- })
169
- .typeDefs(`
170
- extend type Query {
171
- hello: String
172
- }
173
- `)
174
- .resolvers({
175
- Query: {
176
- hello: () => 'Hello from builder!'
177
- }
178
- });
126
+ declare module 'buro26-strapi-graphql' {
127
+ interface Register {
128
+ user: User;
129
+ types: { Customer: Customer; Order: Order };
130
+ }
131
+ }
132
+
133
+ createGraphQLFieldBuilder()
134
+ .rootType('Customer') // parent is now typed as Customer
135
+ .fieldName('fullName')
136
+ .outputType('String')
137
+ .resolver((customer) => `${customer?.firstName} ${customer?.lastName}`);
138
+ ```
139
+
140
+ For registered entity types, `parent` is `Partial<Entity>` with `id` required: Strapi always passes the entity,
141
+ but relations are only present when populated. Unregistered `Query`/`Mutation` fields keep `parent: any`.
179
142
  ```
180
143
 
181
- In this pattern, `createGraphQLExtension()` detects that no argument was passed and returns a builder. The builder exposes methods like `.init()`, `.typeDefs()`, `.types()`, `.plugins()`, `.resolvers()`, and `.resolversConfig()` so you can chain calls and define your extension inline.
144
+ ---
182
145
 
183
- ### Shorter way to define a new field
146
+ ### **Why use the Field Builder?**
184
147
 
185
- The `createGraphQLFieldBuilder()` function provides a fluent builder API to create a single GraphQL field extension using Nexus. It simplifies the process of extending the GraphQL schema by letting you define the field's name, arguments, output type, resolver, and resolver configuration in a chainable way.
148
+ - **Chainable, immutable API** for safe, readable code
149
+ - **Type-safe context and args** for resolvers, middleware, and auth
150
+ - **Built-in support for:**
151
+ - Middleware and authorization (local and global)
152
+ - Argument validation (Zod)
153
+ - Tracing/profiling
154
+ - Deprecation and description
155
+ - Field hooks (before/after resolve)
156
+ - Testing in isolation
157
+ - Batch/group registration
158
+ - Composable presets (`.use()`)
186
159
 
187
- The builder pattern ensures that you only need to specify the necessary parts, and the extension is automatically registered using the provided settings. This approach minimizes boilerplate code while ensuring consistency across your GraphQL extensions.
160
+ ---
188
161
 
189
- Below is an example of how to define a new field using the builder pattern. This example creates a new query field called `helloWorld` that takes a non-null `name` argument and returns a non-null `String`.
162
+ ### **Composable Presets**
163
+
164
+ Create reusable sets of middleware/auth logic and apply them to any field:
190
165
 
191
166
  ```typescript
192
- import { createGraphQLFieldBuilder } from "buro26-strapi-graphql";
193
- import { nonNull, stringArg } from 'nexus';
167
+ const adminPreset = createGraphQLFieldPreset(builder =>
168
+ builder
169
+ .middleware(requireAdmin)
170
+ .authorize(adminAuth)
171
+ );
194
172
 
195
173
  export default createGraphQLFieldBuilder('Query')
196
- .fieldName('helloWorld')
197
- .args({
198
- name: nonNull(stringArg()),
199
- })
200
- .outputType(nonNull('String'))
201
- .resolver(async (parent, args, ctx) => {
202
- return `Hello World ${args.name}`;
203
- })
204
- .resolverConfig({
205
- auth: false,
206
- });
174
+ .use(adminPreset)
175
+ .fieldName('adminHello')
176
+ .outputType('String')
177
+ .resolver(() => 'Hello, admin!');
207
178
  ```
208
179
 
209
- ### Overriding a field
180
+ ---
210
181
 
211
- Sometimes you will need to override a field in an existing type. You can do this by extending the type and adding the
212
- field again. The field will be overridden with the new field definition. To make this easier we added a helper function
213
- `overrideField` that will make this easier. The helper method also automatically generates a resolver for the field.
182
+ ## πŸ”’ Field-Level Authorization and Middleware
214
183
 
215
- The overrideField function needs the content type and fieldName to be able to resolve the field type and the resolver.
216
- The authorize function is optional and can be used to authorize the field. The resolve function is also optional and can
217
- be used to resolve the field.
184
+ Every field defined with `createGraphQLFieldBuilder()` or via Nexus’s `t.field` can use the `extensions` property to
185
+ attach **authorization** and **middleware** logic.
186
+
187
+ ### **Field-Level Authorization**
188
+
189
+ - Use the `authorize` field in `extensions` to add per-field authorization logic.
190
+ - The function should return `true` (allow), `false` (deny), or throw an error.
191
+
192
+ **Example:**
218
193
 
219
194
  ```typescript
220
- import { createGraphQLExtension, overrideField } from 'buro26-strapi-graphql';
221
- import { extendType } from 'nexus';
195
+ t.field('myField', {
196
+ type: 'String',
197
+ extensions: {
198
+ authorize: async (parent, args, ctx, info) => {
199
+ // Only allow admins
200
+ return ctx.state.user?.role === 'admin';
201
+ }
202
+ },
203
+ resolve: () => 'Secret data'
204
+ });
205
+ ```
222
206
 
223
- export default createGraphQLExtension(strapi => ({
224
- types() {
225
- return [
226
- // Extend an existing type
227
- extendType({
228
- type: 'MyType',
229
- definition(t) {
230
- overrideField<MyContentType>(t, {
231
- contentTypeName: 'api::my-type.my-type',
232
- fieldName: 'myField',
233
- description: 'My field description',
234
- authorize: async (root, args, context) => {
235
- // Authorization logic
236
- return true;
237
- },
238
- resolve: async (root) => {
239
- // Resolver is optional
240
- return false;
241
- }
242
- });
243
- }
244
- })
207
+ > **Note:**
208
+ > If you use the builder, `.authorize()` will automatically set this property for you.
245
209
 
246
- ];
247
- }
248
- }));
210
+ ---
211
+
212
+ ### **Field-Level Middleware**
213
+
214
+ - Use the `middlewares` field in `extensions` to add one or more middleware functions to a field.
215
+ - Middleware functions wrap the resolver and can perform logic before/after, modify args, or short-circuit the resolver.
216
+
217
+ **Example:**
218
+
219
+ ```typescript
220
+ t.field('myField', {
221
+ type: 'String',
222
+ extensions: {
223
+ middlewares: [
224
+ next => async (root, args, ctx, info) => {
225
+ console.log('Before');
226
+ const result = await next(root, args, ctx, info);
227
+ console.log('After');
228
+ return result;
229
+ }
230
+ ]
231
+ },
232
+ resolve: () => 'With middleware'
233
+ });
249
234
  ```
250
235
 
251
- ### Add authorization to an existing field
236
+ > **Note:**
237
+ > If you use the builder, `.middleware()` will automatically set this property for you.
238
+
239
+ ---
240
+
241
+ ## 🌍 Global Middleware and Authorization
242
+
243
+ You can register **global middleware** and **global authorization** that apply to multiple fields based on type, field
244
+ name, or tags.
252
245
 
253
- By default Strapi GraphQL does not support field level authorization. This plugin adds support for field level
254
- authorization. You can add authorization to an existing field by using the before mentioned `overrideField` function.
255
- The authorize function will be called before the field is resolved. If the authorize function returns false the field
256
- will not be resolved and an error will be thrown.
246
+ Global middleware and authorization are matched when the GraphQL schema is built, so they apply regardless of
247
+ the order in which files are loaded. The matcher receives `{ type, fieldName, tags, contentType, isBuilderField }`.
257
248
 
258
- If you decide not to pass the resolve function and only the authorize function, the field will be resolved by the
259
- default resolver generated by the plugin.
249
+ 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:
260
251
 
261
252
  ```typescript
262
- import { createGraphQLExtension, overrideField } from 'buro26-strapi-graphql';
263
- import { extendType } from 'nexus';
253
+ registerGlobalAuthorize(() => true, rateLimit, { includeNonBuilderFields: true });
254
+ ```
264
255
 
265
- export default createGraphQLExtension(strapi => ({
266
- types() {
267
- return [
268
- // Extend an existing type
269
- extendType({
270
- type: 'MyType',
271
- definition(t) {
272
- overrideField<MyContentType>(t, {
273
- contentTypeName: 'api::my-type.my-type',
274
- fieldName: 'myField',
275
- authorize: async (root, args, context) => {
276
- // Decide if the user is authorized to access the field
277
- return true;
278
- }
279
- });
280
- }
281
- })
256
+ **Example:**
282
257
 
283
- ];
258
+ ```typescript
259
+ import { registerGlobalMiddleware, registerGlobalAuthorize } from 'buro26-strapi-graphql';
260
+
261
+ // Register global middleware for all admin-tagged Query fields
262
+ registerGlobalMiddleware(
263
+ ({ type, tags }) => type === 'Query' && tags.includes('admin'),
264
+ next => async (root, args, ctx, info) => {
265
+ // ...admin logic...
266
+ return next(root, args, ctx, info);
284
267
  }
285
- }));
268
+ );
269
+
270
+ // Register global authorization for a specific field
271
+ registerGlobalAuthorize(
272
+ ({ fieldName }) => fieldName === 'deleteUser',
273
+ async (parent, args, ctx) => ctx.state.user?.isAdmin
274
+ );
275
+
276
+ // Now define fields that will use these global middlewares/authorizations
277
+ export default createGraphQLFieldBuilder('Query')
278
+ .fieldName('deleteUser')
279
+ .outputType('Boolean')
280
+ .tag('admin')
281
+ .resolver(() => true);
286
282
  ```
287
283
 
288
- ### Field middleware
284
+ **Order of execution per field:** authentication (`withAuth(true)`) β†’ global authorizes β†’ the field's own
285
+ `authorize` β†’ `protectFields` authorizes β†’ global middlewares β†’ the field's own middlewares β†’ `protectFields`
286
+ middlewares β†’ resolver (argument validation, hooks, tracing).
289
287
 
290
- You can add middleware to a specific field, similarly to adding authorization. Each field can have multiple middlewares.
288
+ ---
291
289
 
292
- This can be useful when you want to add some logic before or after the field is resolved. E.g. logging, caching,
293
- response parsing, etc.
290
+ ## πŸ“ Best Practices
294
291
 
295
- ```typescript
296
- import { createGraphQLExtension, overrideField } from 'buro26-strapi-graphql';
297
- import { extendType } from 'nexus';
292
+ - Use the **builder’s `.authorize()` and `.middleware()`** methods for most use-casesβ€”they automatically set the correct
293
+ `extensions` properties.
294
+ - Use the `extensions` property directly for advanced or manual Nexus usage.
295
+ - Use **`protectFields()`** to restrict Strapi's generated fields and **`.relation()`** for relation fields.
296
+ - Use **tags** to target groups of fields for global logic.
298
297
 
299
- export default createGraphQLExtension(strapi => ({
300
- types() {
301
- return [
302
- // Extend an existing type
303
- extendType({
304
- type: 'MyType',
305
- definition(t) {
306
- t.field('myField', {
307
- type: 'MyFieldType',
308
- extensions: {
309
- middlewares: [
310
- next => async (root, args, context, info) => {
311
- // Do something before the field is resolved
298
+ ---
312
299
 
313
- const res = await next(root, args, context, info);
300
+ ### **Validation, Tracing, and Testing**
314
301
 
315
- // Do something after the field is resolved
302
+ **Argument validation with Zod** (also types the resolver's `args` as the parsed schema output):
316
303
 
317
- return res;
318
- }
319
- ]
320
- },
321
- resolve: async (root) => {
322
- // Resolver is optional
323
- }
324
- })
325
- overrideField<MyContentType>(t, {
326
- contentTypeName: 'api::my-type.my-type',
327
- fieldName: 'myField',
328
- extensions: {
329
- middlewares: [
330
- next => async (root, args, context, info) => {
331
- // Do something before the field is resolved
304
+ ```typescript
305
+ import { z } from 'zod';
332
306
 
333
- const res = await next(root, args, context, info);
307
+ const nameSchema = z.object({ name: z.string().min(1) });
334
308
 
335
- // Do something after the field is resolved
309
+ export default createGraphQLFieldBuilder('Query')
310
+ .fieldName('hello')
311
+ .args({ name: 'String!' })
312
+ .outputType('String')
313
+ .validateArgs(nameSchema)
314
+ .resolver((parent, args) => `Hello, ${args.name}!`);
315
+ ```
336
316
 
337
- return res;
338
- }
339
- ]
340
- }
341
- });
342
- }
343
- })
317
+ **Tracing/profiling:**
344
318
 
345
- ];
346
- }
347
- }));
319
+ ```typescript
320
+ export default createGraphQLFieldBuilder('Query')
321
+ .fieldName('profiledHello')
322
+ .outputType('String')
323
+ .trace() // Pretty console log by default
324
+ .resolver(async () => {
325
+ await new Promise(r => setTimeout(r, 100));
326
+ return 'Hello, profiled!';
327
+ });
348
328
  ```
349
329
 
350
- ### Utility functions
330
+ **Testing in isolation** (runs the same pipeline as in production: global and field authorization, `withAuth`,
331
+ middlewares, argument validation and hooks; pass `skipAuth` / `skipMiddleware` to leave parts out):
351
332
 
352
- The plugin also provides utility functions to help you resolve entities and collections. These functions are useful when
353
- you need to resolve entities and collections in your resolvers.
333
+ ```typescript
334
+ const field = createGraphQLFieldBuilder('Query')
335
+ .fieldName('hello')
336
+ .outputType('String')
337
+ .resolver((parent, args, ctx) => `Hello, ${args.name}!`);
338
+
339
+ const result = await field.test({
340
+ args: { name: 'Henri' },
341
+ context: { user: { id: '123' } }
342
+ });
343
+ console.log(result); // "Hello, Henri!"
344
+ ```
345
+
346
+ ---
354
347
 
355
- #### resolveEntity
348
+ ### **Batch Registration and Field Groups**
356
349
 
357
- The `resolveEntity` function can be used to resolve an entity by id. The function takes the content type name and an
358
- object with all the resolve args passed the resolver function. The function will return the resolved entity as
359
- EntityResponse.
350
+ **Register multiple fields at once:**
360
351
 
361
352
  ```typescript
362
- import { createGraphQLExtension, overrideField, resolveEntity } from 'buro26-strapi-graphql';
363
- import { extendType } from 'nexus';
353
+ import { createGraphQLFieldBuilder, registerFields } from 'buro26-strapi-graphql';
364
354
 
365
- export default createGraphQLExtension(strapi => ({
366
- types() {
367
- return [
368
- // Extend an existing type
369
- extendType({
370
- type: 'MyType',
371
- definition(t) {
372
- t.field('myFields', {
373
- type: 'MyFieldEntityResponse',
374
- resolve: async (parent, args, context, info) => {
375
- // Do something, and resolve the entit
376
- return resolveEntity<MyContentType>('api::my-content-type.my-content-type', {
377
- args: {
378
- id: 'the id here',
379
- },
380
- parent,
381
- context,
382
- info,
383
- })
384
- }
385
- });
386
- overrideField<MyContentType>(t, {
387
- contentTypeName: 'api::my-type.my-type',
388
- fieldName: 'myOtherField',
389
- resolve: async (parent, args, context, info) => {
390
- // Do something, and resolve the entit
391
- return resolveEntity<MyContentType>('api::my-content-type.my-content-type', {
392
- args: {
393
- id: 'the id here',
394
- },
395
- parent,
396
- context,
397
- info,
398
- })
399
- }
400
- });
401
- }
402
- })
355
+ const fieldA = createGraphQLFieldBuilder('Query')
356
+ .fieldName('foo')
357
+ .outputType('String')
358
+ .resolver(() => 'Foo');
403
359
 
404
- ];
405
- }
406
- }));
360
+ const fieldB = createGraphQLFieldBuilder('Query')
361
+ .fieldName('bar')
362
+ .outputType('String')
363
+ .resolver(() => 'Bar');
364
+
365
+ export default registerFields(fieldA, fieldB);
366
+ ```
367
+
368
+ **Apply shared logic to a group:**
369
+
370
+ ```typescript
371
+ import { createFieldGroup } from 'buro26-strapi-graphql';
372
+
373
+ const requireUser = builder =>
374
+ builder.middleware(next => async (root, args, ctx, info) => {
375
+ if (!ctx.user) throw new Error('Not authenticated');
376
+ return next(root, args, ctx, info);
377
+ });
378
+
379
+ export default createFieldGroup([fieldA, fieldB], requireUser);
407
380
  ```
408
381
 
409
- #### resolveEntityRelation
382
+ ---
410
383
 
411
- The `resolveEntityRelation` function can be used to resolve an entity by id. The function takes the content type name and an
412
- object with all the resolve args passed the resolver function. The function will return the resolved entity as
413
- EntityResponse.
384
+ ## πŸ› οΈ Advanced: The Extension Builder
385
+
386
+ For advanced use-cases, you can use `createGraphQLExtension()` to build a full extension with custom types, resolvers,
387
+ plugins, and more.
414
388
 
415
389
  ```typescript
416
- import { createGraphQLExtension, overrideField, resolveEntityRelation } from 'buro26-strapi-graphql';
390
+ import { createGraphQLExtension } from 'buro26-strapi-graphql';
417
391
  import { extendType } from 'nexus';
418
392
 
419
- export default createGraphQLExtension(strapi => ({
420
- types() {
421
- return [
422
- // Extend an existing type
423
- extendType({
424
- type: 'MyType',
425
- definition(t) {
426
- t.field('myFields', {
427
- type: 'MyFieldEntityResponse',
428
- resolve: async (parent, args, context, info) => {
429
- // Do something, and resolve the entit
430
- return resolveEntityRelation<MyContentType>('api::my-content-type.my-content-type', {
431
- args: {
432
- id: 'the id here',
433
- },
434
- parent,
435
- context,
436
- info,
437
- })
438
- }
439
- });
440
- overrideField<MyContentType>(t, {
441
- contentTypeName: 'api::my-type.my-type',
442
- fieldName: 'myOtherField',
443
- resolve: async (parent, args, context, info) => {
444
- // Do something, and resolve the entit
445
- return resolveEntityRelation<MyContentType>('api::my-content-type.my-content-type', 'myRelationField', {
446
- args,
447
- parent, // needs to have the id field populated
448
- context,
449
- info,
450
- })
451
- }
452
- });
453
- }
454
- })
455
-
456
- ];
457
- }
458
- }));
393
+ export default createGraphQLExtension()
394
+ .typeDefs(`
395
+ type HelloWorldResponse {
396
+ greeting: String!
397
+ }
398
+ `)
399
+ .types([
400
+ extendType({
401
+ type: 'Query',
402
+ definition(t) {
403
+ t.field('helloWorld', {
404
+ type: 'HelloWorldResponse',
405
+ resolve: () => ({ greeting: 'Hello, world!' })
406
+ });
407
+ }
408
+ })
409
+ ])
410
+ .resolvers({
411
+ Query: {
412
+ helloWorld: () => ({ greeting: 'Hello, world!' })
413
+ }
414
+ });
459
415
  ```
460
416
 
461
- #### resolveEntityCollection
417
+ > **Note:**
418
+ > For most use-cases, prefer the field builder.
419
+ > Use the extension builder for advanced scenarios (custom plugins, full type overrides, etc).
462
420
 
463
- The `resolveEntityCollection` function can be used to resolve a collection of entities. The function takes the content
464
- type name and an object with all the resolve args passed the resolver function. The function will return the resolved
465
- collection as EntityCollectionResponse.
421
+ ---
422
+
423
+ ## 🧩 Overriding and Extending Fields
424
+
425
+ > To only restrict access to generated fields, use [`protectFields()`](#️-protecting-strapis-generated-fields);
426
+ > for relations, use [`.relation()`](#-relations). Use `overrideField` when you need to replace a field's resolver.
427
+
428
+ You can override or extend fields in existing types using the `overrideField` helper. Pass authorization via
429
+ `authorize` (or `extensions.authorize`); relation fields are generated for both Strapi v4 and v5:
466
430
 
467
431
  ```typescript
468
- import { createGraphQLExtension, overrideField, createEntityCollectionResolver } from 'buro26-strapi-graphql';
432
+ import { createGraphQLExtension, overrideField } from 'buro26-strapi-graphql';
469
433
  import { extendType } from 'nexus';
470
434
 
471
435
  export default createGraphQLExtension(strapi => ({
472
436
  types() {
473
437
  return [
474
- // Extend an existing type
475
438
  extendType({
476
439
  type: 'MyType',
477
440
  definition(t) {
478
- t.field('myFields', {
479
- type: 'MyFieldEntityResponseCollection',
480
- resolve: async (parent, args, context, info) => {
481
- // Do something, and resolve the entity
482
- const resolver = createEntityCollectionResolver<MyContentType>('api::my-content-type.my-content-type');
483
- return resolver({
484
- args,
485
- parent,
486
- context,
487
- info,
488
- })
489
- }
490
- });
491
441
  overrideField<MyContentType>(t, {
492
442
  contentTypeName: 'api::my-type.my-type',
493
- fieldName: 'myOtherField',
494
- resolve: async (parent, args, context, info) => {
495
- // Do something, and resolve the entity
496
- const resolver = createEntityCollectionResolver<MyContentType>('api::my-content-type.my-content-type');
497
- return resolver({
498
- args,
499
- parent,
500
- context,
501
- info,
502
- })
443
+ fieldName: 'myField',
444
+ authorize: async (root, args, context) => {
445
+ // Authorization logic
446
+ return true;
447
+ },
448
+ resolve: async (root) => {
449
+ // Resolver is optional
450
+ return false;
503
451
  }
504
452
  });
505
453
  }
506
454
  })
507
-
508
455
  ];
509
456
  }
510
457
  }));
511
458
  ```
512
459
 
513
- #### resolveEntityRelationCollection
460
+ ---
514
461
 
515
- This function has a similar API as `createEntityRelationCollectionResolver` but is used to resolve a collection of entities from a
516
- relational field on the provided content type. The only difference is that the resolvers first argument is the relation field name that should be resolved.
462
+ ## 🧰 Utility Functions
517
463
 
518
- #### toEntityResponse
464
+ This package provides a set of utility functions to make working with Strapi GraphQL resolvers easier and more typesafe:
519
465
 
520
- This function is a typesafe response builder for a single entity. It takes the entity and the content type name and will
521
- return an EntityResponse.
466
+ ### **Entity and Collection Resolvers**
522
467
 
523
- This function is re-exported from the Strapi GraphQL plugin.
468
+ - **`resolveEntity`**
469
+ Resolve a single entity by ID.
524
470
 
525
- #### toEntityCollectionResponse
471
+ ```typescript
472
+ import { resolveEntity } from 'buro26-strapi-graphql';
526
473
 
527
- This function is a typesafe response builder for a collection of entities. It takes the entities and the content type
528
- name and will return an EntityCollectionResponse.
474
+ // Inside a resolver:
475
+ return resolveEntity<MyContentType>('api::my-content-type.my-content-type', {
476
+ args: { id: 'the id here' },
477
+ parent,
478
+ context,
479
+ info,
480
+ });
481
+ ```
529
482
 
530
- This function is re-exported from the Strapi GraphQL plugin.
483
+ - **`resolveEntityRelation`**
484
+ Resolve a related entity from a relation field.
531
485
 
532
- #### resolveArgs
486
+ ```typescript
487
+ import { resolveEntityRelation } from 'buro26-strapi-graphql';
533
488
 
534
- This function is a typesafe utility function to resolve the args passed to the resolver function. It will transform the
535
- graphql
536
- args into args that can be passed to the entityManager.
489
+ return resolveEntityRelation<MyContentType>(
490
+ 'api::my-content-type.my-content-type',
491
+ 'relationFieldName',
492
+ { args, parent, context, info }
493
+ );
494
+ ```
537
495
 
538
- This function is re-exported from the Strapi GraphQL plugin.
496
+ - **`resolveEntityCollection`**
497
+ Resolve a collection of entities.
539
498
 
540
- #### shadowCRUD
499
+ ```typescript
500
+ import { createEntityCollectionResolver } from 'buro26-strapi-graphql';
541
501
 
542
- This function is a typesafe utility function to create shadow CRUD operations for a content type. It will create the CRUD operations for the content type and return the shadow CRUD object.
502
+ const resolver = createEntityCollectionResolver<MyContentType>('api::my-content-type.my-content-type');
503
+ return resolver({ args, parent, context, info });
504
+ ```
543
505
 
544
- ```typescript
545
- import { createGraphQLExtension, shadowCRUD } from 'buro26-strapi-graphql';
506
+ - **`resolveEntityRelationCollection`**
507
+ Resolve a collection of related entities from a relation field.
546
508
 
547
- export default createGraphQLExtension((strapi) => ({
509
+ ```typescript
510
+ import { resolveEntityRelationCollection } from 'buro26-strapi-graphql';
548
511
 
549
- init() {
550
- shadowCRUD('api::article.article')
551
- .disableActions(['create', 'update', 'delete']);
512
+ return resolveEntityRelationCollection<MyContentType>(
513
+ 'api::my-content-type.my-content-type',
514
+ 'relationFieldName',
515
+ { args, parent, context, info }
516
+ );
517
+ ```
552
518
 
553
- shadowCRUD('api::category.category')
554
- .field('title')
555
- .disable();
556
- }
519
+ ### **Response Builders**
557
520
 
558
- }));
559
- ```
521
+ - **`toEntityResponse`**
522
+ Typesafe builder for a single entity response.
523
+
524
+ ```typescript
525
+ import { toEntityResponse } from 'buro26-strapi-graphql';
526
+
527
+ return toEntityResponse(entity, 'api::my-content-type.my-content-type');
528
+ ```
529
+
530
+ - **`toEntityCollectionResponse`**
531
+ Typesafe builder for a collection response.
532
+
533
+ ```typescript
534
+ import { toEntityCollectionResponse } from 'buro26-strapi-graphql';
535
+
536
+ return toEntityCollectionResponse(entities, 'api::my-content-type.my-content-type');
537
+ ```
560
538
 
561
- ## Test and Deploy
539
+ ### **Argument and CRUD Utilities**
562
540
 
563
- ### Running tests
541
+ - **`resolveArgs`**
542
+ Typesafe utility to transform GraphQL args for use with Strapi’s entity manager.
564
543
 
565
- To run tests, run the following command:
544
+ ```typescript
545
+ import { resolveArgs } from 'buro26-strapi-graphql';
546
+
547
+ const entityManagerArgs = resolveArgs(graphqlArgs);
548
+ ```
549
+
550
+ - **`shadowCRUD`**
551
+ Typesafe utility to create shadow CRUD operations for a content type.
552
+
553
+ ```typescript
554
+ import { shadowCRUD } from 'buro26-strapi-graphql';
555
+
556
+ shadowCRUD('api::article.article')
557
+ .disableActions(['create', 'update', 'delete']);
558
+
559
+ shadowCRUD('api::category.category')
560
+ .field('title')
561
+ .disable();
562
+ ```
563
+
564
+ ---
565
+
566
+ ## πŸ§ͺ Testing
567
+
568
+ To run tests:
566
569
 
567
570
  ```bash
568
571
  bun test
569
572
  ```
570
573
 
571
- ## Contributing
574
+ Tests live in `test/` and run against real Nexus/GraphQL schemas with a minimal `strapi` mock.
572
575
 
573
- Wish to contribute to this project? Pull the project from the repository and create a merge request.
576
+ ---
574
577
 
575
- ## Authors and acknowledgment
578
+ ## 🀝 Contributing
579
+
580
+ Pull requests and contributions are welcome!
581
+ See the repo for details.
582
+
583
+ ---
584
+
585
+ ## πŸ‘₯ Authors and Acknowledgment
586
+
587
+ **Buro26** – [https://buro26.digital](https://buro26.digital)
576
588
 
577
- Buro26 - https://buro26.digital
578
589
  Special thanks to all contributors and the open-source community for their support and contributions.
579
590
 
580
- ## License
591
+ ---
592
+
593
+ ## πŸ“„ License
581
594
 
582
595
  This project is licensed under the MIT License - see the LICENSE file for details.
583
596
 
584
- ## Project status
597
+ ---
598
+
599
+ ## 🚦 Project Status
600
+
601
+ Active development.
602
+ Check issues for updates and planned features.
603
+
604
+ ---
585
605
 
586
- The project is currently in active development. We are continuously working on adding new features and improving the
587
- existing ones. Check the issues section for the latest updates and planned features.
606
+ **Questions or suggestions?**
607
+ Open an issue or reach out to [Buro26](https://buro26.digital).
588
608
 
589
- Feel free to reach out if you have any questions or suggestions!