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.
- package/README.md +458 -439
- package/dist/server/index.d.ts +14 -8
- package/dist/server/index.js +1 -0
- package/dist/server/nexus/index.d.ts +1 -0
- package/dist/server/nexus/index.js +17 -0
- package/dist/server/nexus/plugins/field-authorize.plugin.d.ts +36 -0
- package/dist/server/nexus/plugins/field-authorize.plugin.js +122 -0
- package/dist/server/nexus/plugins/index.d.ts +2 -0
- package/dist/server/nexus/plugins/index.js +18 -0
- package/dist/server/nexus/plugins/middleware.plugin.d.ts +25 -0
- package/dist/server/nexus/plugins/middleware.plugin.js +63 -0
- package/dist/server/register.js +6 -7
- package/dist/server/services/file-service.d.ts +7 -6
- package/dist/server/services/file-service.js +36 -30
- package/dist/server/services/index.d.ts +1 -1
- package/dist/server/types.d.ts +28 -13
- package/dist/server/utils/collection-type.d.ts +13 -2
- package/dist/server/utils/collection-type.js +13 -2
- package/dist/server/utils/field-builder/builder.d.ts +33 -0
- package/dist/server/utils/field-builder/builder.js +552 -0
- package/dist/server/utils/field-builder/helpers.d.ts +34 -0
- package/dist/server/utils/field-builder/helpers.js +78 -0
- package/dist/server/utils/field-builder/index.d.ts +3 -0
- package/dist/server/utils/field-builder/index.js +19 -0
- package/dist/server/utils/field-builder/types.d.ts +371 -0
- package/dist/server/utils/field-builder/types.js +2 -0
- package/dist/server/utils/graphql-extension.d.ts +29 -3
- package/dist/server/utils/graphql-extension.js +94 -26
- package/dist/server/utils/index.d.ts +3 -1
- package/dist/server/utils/index.js +12 -1
- package/dist/server/utils/loaders.d.ts +94 -0
- package/dist/server/utils/loaders.js +166 -0
- package/dist/server/utils/misc.d.ts +6 -0
- package/dist/server/utils/misc.js +6 -0
- package/dist/server/utils/override.d.ts +34 -1
- package/dist/server/utils/override.js +54 -26
- package/dist/server/utils/registry/field-protection.d.ts +43 -0
- package/dist/server/utils/registry/field-protection.js +65 -0
- package/dist/server/utils/registry/global-registry.d.ts +63 -0
- package/dist/server/utils/registry/global-registry.js +56 -0
- package/dist/server/utils/registry/index.d.ts +3 -0
- package/dist/server/utils/registry/index.js +19 -0
- package/dist/server/utils/registry/resolve-field.d.ts +1 -0
- package/dist/server/utils/registry/resolve-field.js +67 -0
- package/dist/server/utils/shadow-crud.d.ts +28 -1
- package/dist/server/utils/shadow-crud.js +32 -1
- package/dist/server/utils/single-type.d.ts +30 -1
- package/dist/server/utils/single-type.js +25 -0
- package/dist/server/utils/strapi-compat.d.ts +85 -0
- package/dist/server/utils/strapi-compat.js +194 -0
- package/dist/server/utils/types.d.ts +25 -1
- package/dist/tsconfig.server.tsbuildinfo +1 -1
- package/package.json +7 -4
- package/dist/server/utils/field-builder.d.ts +0 -31
- package/dist/server/utils/field-builder.js +0 -113
- package/dist/server/utils/middleware.d.ts +0 -17
- package/dist/server/utils/middleware.js +0 -77
package/README.md
CHANGED
|
@@ -1,589 +1,608 @@
|
|
|
1
1
|
# Strapi GraphQL Extension
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
7
|
-
|
|
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
|
-
|
|
10
|
+
> **Supports Strapi v4 and v5**
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## π Getting Started
|
|
12
15
|
|
|
13
16
|
```bash
|
|
14
17
|
npm i buro26-strapi-graphql
|
|
15
18
|
```
|
|
16
19
|
|
|
17
|
-
|
|
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
|
-
###
|
|
27
|
+
### **Example: A Custom Query Field**
|
|
20
28
|
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
'
|
|
32
|
-
|
|
33
|
-
}
|
|
34
|
-
// ...
|
|
35
|
-
})
|
|
56
|
+
export default createGraphQLFieldBuilder()
|
|
57
|
+
.rootType('Order')
|
|
58
|
+
.relation('api::order.order', 'customer')
|
|
59
|
+
.authorize(isOrderCustomer);
|
|
36
60
|
```
|
|
37
61
|
|
|
38
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
46
|
-
function as an argument that will receive the Strapi instance and should return an object with the following methods:
|
|
70
|
+
---
|
|
47
71
|
|
|
48
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
62
|
-
import {
|
|
80
|
+
// e.g. src/api/customer/graphql/_protect.ts
|
|
81
|
+
import { protectFields } from 'buro26-strapi-graphql';
|
|
63
82
|
|
|
64
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
162
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
.
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
144
|
+
---
|
|
182
145
|
|
|
183
|
-
###
|
|
146
|
+
### **Why use the Field Builder?**
|
|
184
147
|
|
|
185
|
-
|
|
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
|
-
|
|
160
|
+
---
|
|
188
161
|
|
|
189
|
-
|
|
162
|
+
### **Composable Presets**
|
|
163
|
+
|
|
164
|
+
Create reusable sets of middleware/auth logic and apply them to any field:
|
|
190
165
|
|
|
191
166
|
```typescript
|
|
192
|
-
|
|
193
|
-
|
|
167
|
+
const adminPreset = createGraphQLFieldPreset(builder =>
|
|
168
|
+
builder
|
|
169
|
+
.middleware(requireAdmin)
|
|
170
|
+
.authorize(adminAuth)
|
|
171
|
+
);
|
|
194
172
|
|
|
195
173
|
export default createGraphQLFieldBuilder('Query')
|
|
196
|
-
.
|
|
197
|
-
.
|
|
198
|
-
|
|
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
|
-
|
|
180
|
+
---
|
|
210
181
|
|
|
211
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
221
|
-
|
|
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
|
-
|
|
224
|
-
|
|
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
|
-
|
|
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
|
-
|
|
254
|
-
|
|
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
|
-
|
|
259
|
-
|
|
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
|
-
|
|
263
|
-
|
|
253
|
+
registerGlobalAuthorize(() => true, rateLimit, { includeNonBuilderFields: true });
|
|
254
|
+
```
|
|
264
255
|
|
|
265
|
-
|
|
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
|
-
|
|
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
|
-
|
|
288
|
+
---
|
|
291
289
|
|
|
292
|
-
|
|
293
|
-
response parsing, etc.
|
|
290
|
+
## π Best Practices
|
|
294
291
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
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
|
-
|
|
300
|
+
### **Validation, Tracing, and Testing**
|
|
314
301
|
|
|
315
|
-
|
|
302
|
+
**Argument validation with Zod** (also types the resolver's `args` as the parsed schema output):
|
|
316
303
|
|
|
317
|
-
|
|
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
|
-
|
|
307
|
+
const nameSchema = z.object({ name: z.string().min(1) });
|
|
334
308
|
|
|
335
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
353
|
-
|
|
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
|
-
|
|
348
|
+
### **Batch Registration and Field Groups**
|
|
356
349
|
|
|
357
|
-
|
|
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 {
|
|
363
|
-
import { extendType } from 'nexus';
|
|
353
|
+
import { createGraphQLFieldBuilder, registerFields } from 'buro26-strapi-graphql';
|
|
364
354
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
382
|
+
---
|
|
410
383
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
|
390
|
+
import { createGraphQLExtension } from 'buro26-strapi-graphql';
|
|
417
391
|
import { extendType } from 'nexus';
|
|
418
392
|
|
|
419
|
-
export default createGraphQLExtension(
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
|
|
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
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
|
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: '
|
|
494
|
-
|
|
495
|
-
//
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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
|
-
|
|
460
|
+
---
|
|
514
461
|
|
|
515
|
-
|
|
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
|
-
|
|
464
|
+
This package provides a set of utility functions to make working with Strapi GraphQL resolvers easier and more typesafe:
|
|
519
465
|
|
|
520
|
-
|
|
521
|
-
return an EntityResponse.
|
|
466
|
+
### **Entity and Collection Resolvers**
|
|
522
467
|
|
|
523
|
-
|
|
468
|
+
- **`resolveEntity`**
|
|
469
|
+
Resolve a single entity by ID.
|
|
524
470
|
|
|
525
|
-
|
|
471
|
+
```typescript
|
|
472
|
+
import { resolveEntity } from 'buro26-strapi-graphql';
|
|
526
473
|
|
|
527
|
-
|
|
528
|
-
|
|
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
|
-
|
|
483
|
+
- **`resolveEntityRelation`**
|
|
484
|
+
Resolve a related entity from a relation field.
|
|
531
485
|
|
|
532
|
-
|
|
486
|
+
```typescript
|
|
487
|
+
import { resolveEntityRelation } from 'buro26-strapi-graphql';
|
|
533
488
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
489
|
+
return resolveEntityRelation<MyContentType>(
|
|
490
|
+
'api::my-content-type.my-content-type',
|
|
491
|
+
'relationFieldName',
|
|
492
|
+
{ args, parent, context, info }
|
|
493
|
+
);
|
|
494
|
+
```
|
|
537
495
|
|
|
538
|
-
|
|
496
|
+
- **`resolveEntityCollection`**
|
|
497
|
+
Resolve a collection of entities.
|
|
539
498
|
|
|
540
|
-
|
|
499
|
+
```typescript
|
|
500
|
+
import { createEntityCollectionResolver } from 'buro26-strapi-graphql';
|
|
541
501
|
|
|
542
|
-
|
|
502
|
+
const resolver = createEntityCollectionResolver<MyContentType>('api::my-content-type.my-content-type');
|
|
503
|
+
return resolver({ args, parent, context, info });
|
|
504
|
+
```
|
|
543
505
|
|
|
544
|
-
|
|
545
|
-
|
|
506
|
+
- **`resolveEntityRelationCollection`**
|
|
507
|
+
Resolve a collection of related entities from a relation field.
|
|
546
508
|
|
|
547
|
-
|
|
509
|
+
```typescript
|
|
510
|
+
import { resolveEntityRelationCollection } from 'buro26-strapi-graphql';
|
|
548
511
|
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
512
|
+
return resolveEntityRelationCollection<MyContentType>(
|
|
513
|
+
'api::my-content-type.my-content-type',
|
|
514
|
+
'relationFieldName',
|
|
515
|
+
{ args, parent, context, info }
|
|
516
|
+
);
|
|
517
|
+
```
|
|
552
518
|
|
|
553
|
-
|
|
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
|
-
|
|
539
|
+
### **Argument and CRUD Utilities**
|
|
562
540
|
|
|
563
|
-
|
|
541
|
+
- **`resolveArgs`**
|
|
542
|
+
Typesafe utility to transform GraphQL args for use with Strapiβs entity manager.
|
|
564
543
|
|
|
565
|
-
|
|
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
|
-
|
|
574
|
+
Tests live in `test/` and run against real Nexus/GraphQL schemas with a minimal `strapi` mock.
|
|
572
575
|
|
|
573
|
-
|
|
576
|
+
---
|
|
574
577
|
|
|
575
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
597
|
+
---
|
|
598
|
+
|
|
599
|
+
## π¦ Project Status
|
|
600
|
+
|
|
601
|
+
Active development.
|
|
602
|
+
Check issues for updates and planned features.
|
|
603
|
+
|
|
604
|
+
---
|
|
585
605
|
|
|
586
|
-
|
|
587
|
-
|
|
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!
|