-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmain.ts
More file actions
470 lines (398 loc) · 15.4 KB
/
main.ts
File metadata and controls
470 lines (398 loc) · 15.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
import { createServer } from "node:http";
import { drizzle } from "drizzle-orm/node-postgres";
import {
assertFindFirstExists,
assertFirstEntryExists,
mapNullFieldsToUndefined,
rumble,
} from "../../lib";
import { mergeFilters } from "../../lib/helpers/mergeFilters";
import { relations } from "./db/relations";
import * as schema from "./db/schema";
/*
To use rumble, you first need to define a drizzle database schema and instance.
If you are unfamiliar with this, please follow the excellent getting started guide
in the drizzle docs: https://orm.drizzle.team/docs/get-started
For this example setup there is a simple data seed defined at ./db/seed.ts
*/
export const db = drizzle(
"postgres://postgres:postgres@localhost:5432/postgres",
{
// although drizzle might not force you to pass both schema and relations,
// rumble needs both to be able to infer relations between tables
relations,
schema,
},
);
/*
Next, we can create a rumble instance. The creator returns a set of functions which you
can use to define your objects, queries and mutations.
*/
const {
abilityBuilder,
schemaBuilder,
whereArg,
object,
query,
pubsub,
createYoga,
clientCreator,
} = rumble({
// here we pass the db instance from above
db,
// this is how we can define a context callback
// it takes a request object as an argument and returns the objects you want in the request context
// similar to the context callback in express or similar frameworks
// the type of the request parameter may vary based on the HTTP library you are using
context(_request) {
return {
// for our usecase we simply mock a user ID to be set in the context
// this will allow us to perform permission checks based on who the user is
userId: 2,
};
},
// in case you want to allow searching via string in the helper implementations
// search: {
// enabled: true,
// },
});
/*
Next we will define some abilities. Abilities are things which users are allowed to do.
They consist of an action and, optionally, a condition.
You can imagine abilities as an instruction to rumble: If this is the case, allow that.
You can define as many as you want. Rumble will collect and track them to slowly build
your permissions model which we can later apply to things which happen in our app.
*/
// users can edit themselves
abilityBuilder.users.allow(["read", "update", "delete"]).when(({ userId }) => ({
where: {
id: userId,
},
}));
// everyone can read posts
abilityBuilder.posts.allow("read");
// or define a static condition
// abilityBuilder.posts.allow("read").when({
// where: {
// published: true,
// },
// });
// only the author can update posts
abilityBuilder.posts.allow(["update", "delete"]).when(({ userId }) => ({
where: {
authorId: userId,
},
}));
// a hypothetical more elaborate example
abilityBuilder.posts.allow(["update", "delete"]).when(({ userId }) => {
// we could do some complex checks and calculations here and want to do various things based on the outcome:
if (userId === 1) {
return {
where: {
authorId: userId,
},
}; // we can return a standard ability which allows things under a specific condition
}
if (userId === 2) {
return "allow"; // we can return a wildcard, which allows everything, without any conditions
}
return undefined; // we can return nothing, which does not allow anything
});
// in case you need to apply more complex filters or need async checks you can use the application level filters
// these are function which run after the database query has completed and can be used to do additional filtering
// on the results. Simply return what the user is allowed to see and rumble will take care of the rest
abilityBuilder.posts.filter("read").by(({ context: _context, entities }) => {
// await someAsyncCheck()
// we could apply filters here
return entities;
});
// sometimes you might wish to start some async work the instance the resolve begins. E.g. if you
// want to fetch some permissions from an external service, which are only based on the user's context
// and not on the actual data being queried. Prefetch allows to parallelize the async work with the query resolve
// of the data.
abilityBuilder.posts
.filter("read")
// the prefetch function is called in parallel with the query resolve and thus does not have access to the entities
// returned by the query. This allows to parallelize the async work with the query resolve of the data.
.prefetch(async ({ context }) => {
return { somePrefetch: "data" };
})
// the prefetched data is available in the actual filter function
.by(({ context: _context, entities, prefetched }) => {
return entities;
});
/*
Next we need to define the objects shape which will be returned by our queries and mutations.
We use pothos under the hood to define our graphql schema. It is integrated with the drizzle plugin.
If you are unfamiliar with pothos, please refer to the docs: https://pothos-graphql.dev/docs/plugins/drizzle
Rumble creates a schema builder for you which you can use to define your objects, queries and mutations as
you would with a regular schema builder instance.
*/
// we define the schema of the post object so we can later use it in our queries as a return type
const PostRef = schemaBuilder.drizzleObject("posts", {
name: "Post",
// this is how you can apply application level filter (the one defined by abilityBuilder.posts.filter("read").by)
// in manual object definitions
// the helpers will do this for you automatically
applyFilters: abilityBuilder._.registeredFilters({
action: "read",
table: "posts",
}),
fields: (t) => ({
id: t.exposeInt("id"),
content: t.exposeString("content", { nullable: false }),
author: t.relation("author", {
// this is how you can apply the above abilities to the queries
// you define the action you want the filters for by passing it to the filter call
query: (_args, ctx) =>
// for a findFirst query (1:1 relation)
// we want a query filter
ctx.abilities.users.filter("read").query.single,
}),
}),
});
/*
Since this might get a bit verbose, rumble offers a helper for defining default object implementations.
It will expose all fields and relations but apply the above abilities to the queries so you don't have
to worry about data getting returned which the caller should not be able to see
*/
const UserRef = object({
// name of the table you want to implement ("posts" in the above example)
table: "users",
// optionally specify the name of the object ("Post" in the above example)
// refName: "User",
// optionally, we can extend this with some custom fields (you can also overwrite existing fields in case you need to change default behavior)
adjust(t) {
return {
// the user should have the "somethingElse" field in addition to the default fields
somethingElse: t.field({
type: "String",
args: {
amount: t.arg.int({ required: true }),
},
resolve: (parent, args, _context, _info) =>
`Hello ${parent.name}, you have provided the number: ${args.amount}`,
}),
// here we also could overwrite the "name" field to apply some custom logic
// name: t.exposeString("name", {
// resolve: (parent) => `Mr./Ms. ${parent.name}`
// })
};
},
});
/*
Now we need a way to fetch the users and posts from the database.
We can implement the queries ourselves or use the provided helpers, its up to you.
Manual implementation with the pothos schema builder instance would look something like this:
*/
schemaBuilder.queryFields((t) => {
return {
posts: t.drizzleField({
type: [PostRef],
resolve: (query, _root, _args, ctx, _info) => {
return db.query.posts.findMany(
// here we again apply our filters based on the defined abilities
query(ctx.abilities.posts.filter("read").query.many),
);
},
}),
};
});
/*
In case you want to allow filtering the posts returned by the above query, you would need to
add an arg object to the query definition. You can implement this yourself or use the provided
helper which will automatically create a filter arg for you. See https://pothos-graphql.dev/docs/guide/args
and https://pothos-graphql.dev/docs/guide/inputs for more information for manual implementation.
The rumble helper can implement some filters for you:
*/
const PostWhere = whereArg({
// for which table to implement this
table: "posts",
});
// there is also an orderArg which you can use to apply 'orderBy' just as you can do with 'where'
// now we can use this in a query
schemaBuilder.queryFields((t) => {
return {
postsFiltered: t.drizzleField({
type: [PostRef],
args: {
// here we set our generated type as type for the where argument
where: t.arg({ type: PostWhere }),
},
resolve: (query, _root, args, ctx, _info) => {
// a helper to map null fields to undefined in any object
const mappedArgs = mapNullFieldsToUndefined(args);
return db.query.posts.findMany(
query(
ctx.abilities.posts
.filter("read")
// merge is a helper to merge multiple filter objects into one
// so we can easily apply both the permissions filter and the user provided filter
// just for this one query call
.merge({
where: mappedArgs.where,
}).query.many,
),
);
},
}),
};
});
/*
We can also implement the READ queries by using the provided helper.
The below helper will offer findFirst and findMany implementations
with permissions and filtering applied.
NOTE: Before you call a query for an object, you need to define it first.
Make sure you either do that manually or use the `object` helper as shown above.
*/
query({
table: "users",
});
// for the comments table we fully rely on the helpers. This is all you need to do to get a full
// CRUD api for the comments table with permissions and filtering applied:
object({
table: "comments",
});
query({
table: "comments",
});
/*
Finally, we want to implement the mutations so we can actually edit some data.
We can use the schemaBuilder to do that.
*/
// OPTIONAL: If you want to use graphql subscriptions, you can use the pubsub helper
// this makes notifying subscribers easier. The rumble helpers all support subscriptions
// right out of the box, so all subscriptions will automatically get notified if necessary
// the only thing you have to do is to call the pubsub helper with the table name
// and embed the helper into your mutations or other places where data changes
const { updated: updatedUser, created: createdUser } = pubsub({
table: "users",
});
schemaBuilder.mutationFields((t) => {
return {
updateUsername: t.drizzleField({
type: UserRef,
args: {
userId: t.arg.int({ required: true }),
newName: t.arg.string({ required: true }),
},
resolve: async (query, _root, args, ctx, _info) => {
await db
.update(schema.users)
.set({
name: args.newName,
})
.where(
// we need an sql condition here sowe use .sql.where instead of .query.x as we did above
ctx.abilities.users.filter("update").merge({
where: { id: args.userId },
}).sql.where,
// the output of this is a normal drizzle sql condition and can be further processed with e.g. `and()` etc.
);
// this notifies all subscribers that the user has been updated
updatedUser(args.userId);
return (
db.query.users
.findFirst(
query(
mergeFilters(ctx.abilities.users.filter("read").query.single, {
where: { id: args.userId },
}),
),
)
// this helper maps the db response to a graphql response
// `assertFirstEntryExists` - throws an error if the response array does not contain a single entry
// `assertFindFirstExists` - makes the result of a findFirst query compatible with graphql. Also throws if not present.
.then(assertFindFirstExists)
);
},
}),
};
});
schemaBuilder.mutationFields((t) => {
return {
addUser: t.drizzleField({
type: UserRef,
args: {
id: t.arg.int({ required: true }),
name: t.arg.string({ required: true }),
},
resolve: async (query, _root, args, ctx, _info) => {
// TODO: check if the user is allowed to add a user
const newUser = await db
.insert(schema.users)
.values({
id: args.id,
name: args.name,
})
.returning({
id: schema.users.id,
})
.then(assertFirstEntryExists);
// this notifies all subscribers that a user has been added
createdUser();
return (
db.query.users
// run the db query
.findFirst(
// inject all graphql selections which ought to be queried from the db according to the gql request
query(
// merge multiple filter objects which should be applied to the query
// only retrieve the user if the caller is allowed to read it
ctx.abilities.users.filter("read").merge({
// only retrieve the newly created user
where: { id: newUser.id },
}).query.single,
),
)
.then(assertFindFirstExists)
);
},
}),
};
});
/*
Finally, we can start the server. We use graphql-yoga under the hood. It allows for
a very simple and easy to use GraphQL API and is compatible with many HTTP libraries and frameworks.
*/
// when we are done defining the objects, queries and mutations,
// we can start the server
const server = createServer(createYoga());
server.listen(3000, () => {
console.info("Visit http://localhost:3000/graphql");
});
// if you also need a REST API built from your GraphQL API, you can use 'createSofa()' instead or in addition
/*
That's it for the server/api part! Running the above demo will get you going with a fully functional
GraphQL API with permissions and filtering applied. You can now query users, posts and comments
according to the defined permissions.
We covered most of the relevant features of rumble, but there is more to explore.
*/
// Making calls to the API
// this can run on the dev machine to create a client for
// api consumption. Make sure to call this after registering
// all objects, queries and mutations and only in dev mode
await clientCreator({
rumbleImportPath: "../../../lib/client",
outputPath: "./example/src/generated-client",
apiUrl: "http://localhost:3000/graphql",
});
// it will write a typesafe client to the specified output path on your filesystem
// which then can be used like this:
// import { client } from "./generated-client/client";
// const r1 = client.liveQuery.users({
// id: true,
// name: true,
// });
// r1.subscribe((s) => console.log(s?.at(0)));
// const a = client.subscribe.users({
// id: true,
// name: true,
// });
// a.subscribe((s) => console.log(s.at(0)));
// awaiting this allows us to use the data directly, without subscribing
// const r3 = await client.liveQuery.users({
// id: true,
// name: true,
// });
// console.log(r3.at(0));