lighthouse-graphql 0.2.1 → 0.4.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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +99 -1
  3. data/README.md +22 -3
  4. data/docs/best-practices.md +16 -6
  5. data/docs/caching.md +1 -1
  6. data/docs/configuration.md +74 -5
  7. data/docs/custom-directives.md +11 -1
  8. data/docs/directives.md +333 -2
  9. data/docs/events.md +203 -0
  10. data/docs/federation.md +3 -2
  11. data/docs/getting-started.md +90 -23
  12. data/docs/motivation.md +3 -2
  13. data/docs/mutations.md +15 -0
  14. data/docs/resolvers.md +95 -19
  15. data/docs/security.md +210 -0
  16. data/docs/subscriptions.md +446 -0
  17. data/lib/generators/lighthouse/base_classes.rb +87 -0
  18. data/lib/generators/lighthouse/class_generator.rb +19 -8
  19. data/lib/generators/lighthouse/install/install_generator.rb +23 -6
  20. data/lib/generators/lighthouse/install/templates/graphql_controller.rb.tt +7 -45
  21. data/lib/generators/lighthouse/install/templates/initializer.rb.tt +34 -5
  22. data/lib/generators/lighthouse/install/templates/schema.rb.tt +14 -2
  23. data/lib/generators/lighthouse/mutation_generator.rb +5 -4
  24. data/lib/generators/lighthouse/query_generator.rb +5 -4
  25. data/lib/generators/lighthouse/subscription_generator.rb +100 -0
  26. data/lib/lighthouse/broadcast_job.rb +14 -0
  27. data/lib/lighthouse/broadcasting.rb +143 -0
  28. data/lib/lighthouse/cache_control.rb +5 -2
  29. data/lib/lighthouse/complexity.rb +174 -0
  30. data/lib/lighthouse/complexity_analyzer.rb +56 -0
  31. data/lib/lighthouse/config.rb +56 -14
  32. data/lib/lighthouse/controller.rb +131 -0
  33. data/lib/lighthouse/directive_resolvers/broadcast_resolver.rb +31 -0
  34. data/lib/lighthouse/directive_resolvers/convention_resolver.rb +14 -4
  35. data/lib/lighthouse/directive_resolvers/convert_empty_strings_resolver.rb +134 -0
  36. data/lib/lighthouse/directive_resolvers/event_resolver.rb +37 -0
  37. data/lib/lighthouse/directive_resolvers/model_mutation_resolver.rb +28 -13
  38. data/lib/lighthouse/directive_resolvers/soft_delete_resolvers.rb +66 -0
  39. data/lib/lighthouse/directive_resolvers/subscription_resolver.rb +76 -0
  40. data/lib/lighthouse/directives/arguments/soft_deletes.rb +112 -0
  41. data/lib/lighthouse/directives/registry.rb +15 -6
  42. data/lib/lighthouse/directives/soft_deletes_directive.rb +123 -0
  43. data/lib/lighthouse/events/execution_trace.rb +52 -0
  44. data/lib/lighthouse/events/lifecycle.rb +148 -0
  45. data/lib/lighthouse/events/schema_build.rb +44 -0
  46. data/lib/lighthouse/events.rb +76 -0
  47. data/lib/lighthouse/feature_directive.rb +137 -0
  48. data/lib/lighthouse/field_resolver_stack.rb +28 -7
  49. data/lib/lighthouse/graphql/version.rb +1 -1
  50. data/lib/lighthouse/namespaces.rb +1 -0
  51. data/lib/lighthouse/persisted_queries/batch.rb +34 -0
  52. data/lib/lighthouse/persisted_queries.rb +17 -4
  53. data/lib/lighthouse/rb_lighthouse.rb +23 -0
  54. data/lib/lighthouse/resolver_bases.rb +61 -0
  55. data/lib/lighthouse/schema_cache.rb +12 -5
  56. data/lib/lighthouse/schema_factory.rb +0 -1
  57. data/lib/lighthouse/schema_generator.rb +42 -9
  58. data/lib/lighthouse/sdl_loader.rb +0 -2
  59. data/lib/lighthouse/subscription.rb +120 -0
  60. data/lib/lighthouse/support/model_resolver.rb +1 -1
  61. data/lib/lighthouse/support/soft_deletes.rb +164 -0
  62. data/lib/lighthouse/tasks/lighthouse.rake +10 -0
  63. data/sig/lighthouse/controller.rbs +30 -0
  64. data/sig/lighthouse/events.rbs +122 -0
  65. data/sig/lighthouse/graphql.rbs +62 -0
  66. data/sig/lighthouse.rbs +52 -1
  67. metadata +29 -1
@@ -0,0 +1,446 @@
1
+ # Subscriptions
2
+
3
+ A subscription lets a client ask for a stream of updates: it sends a `subscription`
4
+ operation once, and the server sends a result each time something it cares about
5
+ happens. Lighthouse builds on [graphql-ruby's subscriptions](https://graphql-ruby.org/subscriptions/overview.html)
6
+ and keeps the shape of [PHP Lighthouse's](https://lighthouse-php.com/master/subscriptions/getting-started.html):
7
+
8
+ - a `Subscription` field is declared in the SDL, and **resolves by convention** to a class;
9
+ - that class inherits from `Subscriptions::BaseSubscription`, a graphql-ruby
10
+ [`GraphQL::Schema::Subscription`](https://graphql-ruby.org/subscriptions/subscription_classes.html),
11
+ as a `Query` field's class is a `Resolvers::BaseResolver` and a mutation's a
12
+ `Mutations::BaseMutation` (see [resolvers.md](resolvers.md#base-classes)). The base
13
+ includes [Lighthouse's concern](#the-subscription-concern): PHP
14
+ Lighthouse's model, one topic per field and a `filter?`;
15
+ - a mutation announces what it did with [`@broadcast`](#broadcast-from-a-mutation).
16
+
17
+ ```graphql
18
+ type Subscription {
19
+ postUpdated: Post
20
+ }
21
+
22
+ type Mutation {
23
+ updatePost(id: ID!, title: String): Post! @update @broadcast(subscription: "postUpdated")
24
+ }
25
+ ```
26
+
27
+ ```ruby
28
+ # app/graphql/subscriptions/post_updated.rb (rails generate lighthouse:subscription PostUpdated)
29
+ class Subscriptions::PostUpdated < Subscriptions::BaseSubscription
30
+ # For each broadcast: skip the subscribers who may not see this post.
31
+ def filter?(post, **)
32
+ post.published? || post.author == context[:current_user]
33
+ end
34
+ end
35
+ ```
36
+
37
+ ## Enabling a backend
38
+
39
+ graphql-ruby delivers subscriptions through a *backend* that stores who subscribed
40
+ and sends the updates: ActionCable (bundled), or a gem such as Pusher or Ably.
41
+ Lighthouse needs none of its own; give the schema one. `get_schema` passes the
42
+ `using:` option to graphql-ruby, as `use` does in a class-based schema:
43
+
44
+ ```ruby
45
+ # app/graphql/app_schema.rb
46
+ AppSchema = Lighthouse::GraphQL::RbLightHouse.get_schema(
47
+ options: { using: { GraphQL::Subscriptions::ActionCableSubscriptions => {} } }
48
+ )
49
+ ```
50
+
51
+ Options of the backend go in the hash (`{ ActionCableSubscriptions => { namespace: 'my-app' } }`).
52
+ Assign the schema to a constant: a [queued](#queueing) broadcast finds it by name.
53
+
54
+ Then add the channel graphql-ruby documents, which runs the operation and keeps
55
+ the client's subscription ids ([the full example](https://graphql-ruby.org/subscriptions/action_cable_implementation.html)):
56
+
57
+ ```ruby
58
+ class GraphqlChannel < ApplicationCable::Channel
59
+ def subscribed
60
+ @subscription_ids = []
61
+ end
62
+
63
+ def execute(data)
64
+ result = AppSchema.execute(
65
+ data['query'],
66
+ variables: data['variables'] || {},
67
+ operation_name: data['operationName'],
68
+ context: { channel: self, current_user: current_user }
69
+ )
70
+ @subscription_ids << result.context[:subscription_id] if result.context[:subscription_id]
71
+ transmit({ result: result.to_h, more: result.subscription? })
72
+ end
73
+
74
+ def unsubscribed
75
+ @subscription_ids.each { |id| AppSchema.subscriptions.delete_subscription(id) }
76
+ end
77
+ end
78
+ ```
79
+
80
+ Use a real ActionCable adapter (Redis, PostgreSQL, Solid Cable), not `async`: the
81
+ broadcast is often triggered from a job or a console, another process than the
82
+ one holding the sockets. The root a broadcast carries travels through
83
+ ActionCable's serialization, so an Active Record model must be serializable with
84
+ GlobalID. A Rails app that loads ActiveJob has that; one that picks its railties
85
+ (an API-only `config/application.rb`) needs `require 'global_id/railtie'`, or the
86
+ subscribers would receive the model's `inspect` string.
87
+
88
+ ## Defining subscription fields
89
+
90
+ A field of the `Subscription` type needs nothing but its SDL, and then
91
+ **every broadcast reaches every subscriber, unchanged**: `@broadcast` hands the
92
+ mutation's result to the subscription, which resolves the field with it.
93
+
94
+ ```graphql
95
+ type Subscription {
96
+ postCreated: Post
97
+ }
98
+ ```
99
+
100
+ For anything more, write the class. (A field with no class of its own resolves with
101
+ the schema's `base_types[:subscription]`, else with a built-in class that includes
102
+ the concern: one topic for the field, no `filter?`.) `Subscription.postUpdated` resolves to
103
+ `Subscriptions::PostUpdated`, looked up in `Config.namespaces[:subscriptions]`
104
+ (default `['Subscriptions']`) each time it is needed, so code reloading works.
105
+ `rails generate lighthouse:subscription PostUpdated` writes it. It inherits from
106
+ `Subscriptions::BaseSubscription`, a `GraphQL::Schema::Subscription`: anything you
107
+ know from graphql-ruby applies.
108
+
109
+ Like a query or a mutation class, it declares neither the arguments nor the return
110
+ type: the SDL does. The field's arguments arrive as `snake_case` keywords.
111
+
112
+ ### The lifecycle
113
+
114
+ graphql-ruby calls the class twice in a subscription's life: once for the
115
+ **subscription request** (the client's `subscription { ... }`), and then once for
116
+ **each update** (`@broadcast`, or `schema.subscriptions.trigger`). All hooks are
117
+ optional. These are graphql-ruby's own; the concern `BaseSubscription` includes
118
+ implements `authorized?` and `update` with PHP Lighthouse's hooks
119
+ ([below](#the-subscription-concern)), and a class that defines them itself
120
+ keeps graphql-ruby's behavior.
121
+
122
+ | Hook | Called | Default |
123
+ | --- | --- | --- |
124
+ | `authorized?(**args)` | for the request **and** for every update | `true` |
125
+ | `subscribe(**args)` | for the request: its return value answers the client at once | `:no_response`: nothing yet |
126
+ | `update(**args)` | for each update: its return value is what the field resolves to | `object`, the broadcast root |
127
+ | `unsubscribe` | call it to end the subscription from the server | |
128
+ | `self.subscription_scope` | declare it to give each tenant or user their own stream | none |
129
+ | `self.topic_for(arguments:, field:, scope:)` | the stream a subscription joins and a trigger addresses | the field, its scope and its arguments |
130
+
131
+ - **`args`** are the arguments the client *subscribed* with, in `snake_case`.
132
+ `object` is the broadcast root in `update`, and `context` is the *subscriber's*:
133
+ graphql-ruby stores it with the subscription and replays it for each update, so
134
+ `context[:current_user]` is whoever subscribed.
135
+ - **`update`** returns `NO_UPDATE` (`GraphQL::Schema::Subscription::NO_UPDATE`) to
136
+ skip this subscriber for this broadcast, or another value to send it instead of
137
+ `object`.
138
+ - **`subscribe`** is where to send an initial payload (the current state, say);
139
+ return `:no_response`, or leave it out, to send nothing until the first update.
140
+ - **`authorized?`** runs again for each update, with the subscriber's stored
141
+ context. Raising a `GraphQL::ExecutionError` refuses the request and tells the
142
+ client why; nothing is registered. Returning `false` refuses it silently: the
143
+ field comes back `null`, with no error.
144
+
145
+ ```ruby
146
+ class Subscriptions::OrderShipped < Subscriptions::BaseSubscription
147
+ def authorized?(**)
148
+ raise GraphQL::ExecutionError, 'Sign in to follow your orders.' unless context[:current_user]
149
+
150
+ true
151
+ end
152
+
153
+ def update(**)
154
+ object.user_id == context[:current_user].id ? object : NO_UPDATE
155
+ end
156
+ end
157
+ ```
158
+
159
+ If you know PHP Lighthouse's `GraphQLSubscription`, this is where its methods went:
160
+
161
+ | PHP Lighthouse | graphql-ruby |
162
+ | --- | --- |
163
+ | `authorize(Subscriber, Request): bool` | `authorized?`, which also runs for each update (check `context.query.subscription_update?` to tell them apart) |
164
+ | `filter(Subscriber, $root): bool` | `update` returning `NO_UPDATE` |
165
+ | `resolve($root, $args, $context, $info)` | `update`'s return value |
166
+ | `encodeTopic` / `decodeTopic` | `self.topic_for(arguments:, field:, scope:)` |
167
+ | the subscriber's `$args` | the keywords of `subscribe` and `update` |
168
+ | `Subscription::broadcast($name, $root)` | `@broadcast`, or `schema.subscriptions.trigger` |
169
+
170
+ One real difference is the **topic**. PHP Lighthouse puts every subscriber of a
171
+ field on one topic and `filter` decides who is updated. graphql-ruby's default
172
+ topic includes the arguments, so a trigger reaches only the subscribers whose
173
+ arguments *equal* its own. The concern in `BaseSubscription` takes PHP Lighthouse's
174
+ topic; a class that keeps graphql-ruby's gives `@broadcast` its `trigger_arguments`.
175
+ See [below](#topics-trigger-arguments-and-filtering).
176
+
177
+ ## The subscription concern
178
+
179
+ `Lighthouse::GraphQL::Subscription` is PHP Lighthouse's model: one topic per field,
180
+ `filter?` to narrow it and `resolve_payload` for the result. It is included once, in
181
+ the base every subscription inherits from, as graphql-ruby's convention puts what
182
+ all classes of a kind share in their base:
183
+
184
+ ```ruby
185
+ # app/graphql/subscriptions/base_subscription.rb (rails generate lighthouse:install)
186
+ module Subscriptions
187
+ class BaseSubscription < GraphQL::Schema::Subscription
188
+ include Lighthouse::GraphQL::Subscription
189
+ end
190
+ end
191
+ ```
192
+
193
+ A subscription is still a plain `GraphQL::Schema::Subscription`, and declares its
194
+ arguments as one would in graphql-ruby (or leaves them to the SDL).
195
+ `rails generate lighthouse:subscription PostUpdated` writes one with the hooks:
196
+
197
+ ```ruby
198
+ class Subscriptions::PostUpdated < Subscriptions::BaseSubscription
199
+ def filter?(post, author: nil)
200
+ author.nil? || post.author_id.to_s == author.to_s
201
+ end
202
+ end
203
+ ```
204
+
205
+ The concern maps the hooks of `GraphQLSubscription` onto graphql-ruby's. All are
206
+ optional.
207
+
208
+ | PHP Lighthouse | Concern | Maps onto | Default |
209
+ | --- | --- | --- | --- |
210
+ | `authorize(Subscriber, Request): bool` | `authorize?(**args)` | `authorized?`, on the subscription request only | `true` |
211
+ | `filter(Subscriber, $root): bool` | `filter?(root, **args)` | `update` returns `NO_UPDATE` when it is false | `true` |
212
+ | `resolve($root, $args, $context, $info)` | `resolve_payload(root, **args)` | the value `update` returns | `root` |
213
+ | `encodeTopic` / `decodeTopic` | `self.topic_for(arguments:, field:, scope:)` | graphql-ruby's topic | the field and its scope |
214
+
215
+ - `authorize?` returning false refuses the subscription: the client gets
216
+ `Unauthorized subscription request.` and nothing is registered. Raise a
217
+ `GraphQL::ExecutionError` to say more. Unlike `authorized?`, it is not called again for the updates.
218
+ - `filter?` is where a subscriber is turned down for one broadcast.
219
+ - The concern also adds the class methods `topic_by`, `topic_for`,
220
+ `trigger_arguments` and `trigger_scope`, which `topic_by` and the
221
+ [broadcast section](#topics-trigger-arguments-and-filtering) explain.
222
+ - Including it in a class that is not a `GraphQL::Schema::Subscription` raises a
223
+ `Lighthouse::DefinitionError`.
224
+ - To keep graphql-ruby's model for every subscription, leave the `include` out of
225
+ `BaseSubscription`; the generator then writes classes with `authorized?` and
226
+ `update` instead.
227
+ - **Precedence.** The concern sits between your class and
228
+ `GraphQL::Schema::Subscription` (it is in the base), so what the class defines
229
+ itself wins. A class
230
+ with its own `update` (or `authorized?`) is called instead of the concern's, and
231
+ its `filter?`, `resolve_payload` (or `authorize?`) are not, unless it calls
232
+ `super`. `subscribe`, `unsubscribe`, `subscription_scope` and `ready?` are
233
+ untouched, and combine freely with the hooks.
234
+
235
+ ```ruby
236
+ class Subscriptions::OrderShipped < Subscriptions::BaseSubscription
237
+ def authorize?(**)
238
+ context[:current_user].present?
239
+ end
240
+
241
+ def filter?(order, **)
242
+ order.user_id == context[:current_user].id
243
+ end
244
+
245
+ def resolve_payload(order, **)
246
+ order.decorate
247
+ end
248
+ end
249
+ ```
250
+
251
+ Its topic is the **field** (and its scope), not its arguments: a broadcast reaches
252
+ every subscriber, and `filter?` decides. `topic_by :arguments` (inherited by
253
+ subclasses) turns graphql-ruby's exact matching back on; see below.
254
+
255
+ ## `@subscription`
256
+
257
+ A class that does not follow the naming convention, or lives elsewhere, is named
258
+ with `@subscription(class:)`:
259
+
260
+ ```graphql
261
+ directive @subscription(class: String!) on FIELD_DEFINITION
262
+ ```
263
+
264
+ ```graphql
265
+ type Subscription {
266
+ postUpdated(author: ID!): Post @subscription(class: "Blog::PostUpdatedSubscription")
267
+ }
268
+ ```
269
+
270
+ The name is looked up in `Config.namespaces[:subscriptions]` like the conventional
271
+ one, so `class: "PostUpdated"` finds `Subscriptions::PostUpdated`, and a name with
272
+ `::` is used as it is. A class that does not exist, is not a
273
+ `GraphQL::Schema::Subscription`, or does not inherit from the schema's
274
+ `base_types[:subscription]` raises `Lighthouse::DefinitionError`.
275
+ `@guard`, `@can*` and the other wrapping directives work on a subscription field
276
+ as on any other.
277
+
278
+ ## Broadcast from a mutation
279
+
280
+ `@broadcast` triggers a subscription after its field has resolved:
281
+
282
+ ```graphql
283
+ directive @broadcast(subscription: String!, shouldQueue: Boolean) repeatable on FIELD_DEFINITION
284
+ ```
285
+
286
+ ```graphql
287
+ type Mutation {
288
+ createPost(input: CreatePostInput! @spread): Post! @create @broadcast(subscription: "postCreated")
289
+ updatePost(input: UpdatePostInput! @spread): Post!
290
+ @update
291
+ @broadcast(subscription: "postUpdated", shouldQueue: false)
292
+ @broadcast(subscription: "postsOfAuthor")
293
+ }
294
+ ```
295
+
296
+ - The **result of the field is the root** the subscribers' updates are resolved
297
+ from (`object` in `update`). Each subscriber's operation runs against it, and
298
+ `update` (or `filter?`) can turn a subscriber down.
299
+ - It fires **once the field resolved successfully**: a mutation that raised, was
300
+ rejected by `@guard`, `@can*` or `@validator`, failed model validations, or
301
+ returned `nil` broadcasts nothing. It runs after `@create`/`@update` saved,
302
+ so the transaction they open has committed.
303
+ - It is **repeatable**: every occurrence triggers its own subscription, once.
304
+ - `subscription:` is the field's name in the `Subscription` type (`snake_case`
305
+ works too). One that does not exist is a `Lighthouse::DefinitionError` when the
306
+ schema is built.
307
+ - A field returning a **list** (`@createMany`) broadcasts the list as one root. A
308
+ subscription field of a list type, or a `resolve_payload` that picks from it,
309
+ handles it.
310
+ - A schema with **no subscriptions backend** reports a `Lighthouse::DefinitionError`
311
+ saying how to install one, through `Config.on_error` (raised in a test
312
+ environment, logged otherwise); the mutation itself has succeeded.
313
+
314
+ ### Topics, trigger arguments and filtering
315
+
316
+ graphql-ruby sends an update only to the subscribers whose arguments **equal** the
317
+ arguments of the trigger: `trigger(:post_updated, { author: 1 }, post)` reaches
318
+ `postUpdated(author: 1)` and no one else. `@broadcast` has to know what to trigger
319
+ with, so it asks the class: `self.trigger_arguments(root, context)` returns the
320
+ arguments (a Hash, `{}` by default) for the `root` a mutation returned, and
321
+ `self.trigger_scope(root, context)` the `scope:` for a class declaring
322
+ `subscription_scope` (graphql-ruby's way to give each tenant or user their own
323
+ stream).
324
+
325
+ When the topic includes the arguments (a class with `topic_by :arguments`, or under
326
+ a base that does not include the concern):
327
+
328
+ - **A field without arguments works out of the box.** Every subscriber's arguments
329
+ are `{}`, and so is the trigger's.
330
+ - **A field with arguments needs `trigger_arguments`**, returning the value of every
331
+ argument that identifies the group of subscribers:
332
+
333
+ ```graphql
334
+ type Subscription {
335
+ messagePosted(room: ID!): Message
336
+ }
337
+ ```
338
+
339
+ ```ruby
340
+ class Subscriptions::MessagePosted < Subscriptions::BaseSubscription
341
+ topic_by :arguments
342
+
343
+ # The subscribers of `messagePosted(room: "<id>")`.
344
+ def self.trigger_arguments(message, _context) = { room: message.room_id.to_s }
345
+ end
346
+ ```
347
+
348
+ - **Arguments and no `trigger_arguments` is a misconfiguration**, and it fails
349
+ silently in graphql-ruby: a trigger of `{}` equals no subscriber's arguments, so
350
+ nobody is ever updated. Lighthouse reports it the first time such a field is
351
+ broadcast, with the field, the class and the arguments, as a
352
+ `Lighthouse::DefinitionError` through `Config.on_error` (raised in a test
353
+ environment, logged otherwise), and triggers nothing. It is reported when the
354
+ broadcast happens rather than when the schema is built, because the class is only
355
+ looked up then (so that code reloading works), and only when an argument is
356
+ **required**: that is certain to reach nobody. A field whose arguments are all
357
+ optional is not reported, since its argument-less subscribers do match `{}`; only
358
+ those subscribers are updated, so give such a class a `trigger_arguments` too if
359
+ subscribers pass the argument.
360
+ - **To have one trigger reach subscribers with different arguments** (the argument
361
+ is a filter, not an identity) without the concern, give the class one topic per
362
+ field, as graphql-ruby documents, and let `update` return `NO_UPDATE`:
363
+
364
+ ```ruby
365
+ class Subscriptions::PostPublished < Subscriptions::BaseSubscription # a base without the concern
366
+ def self.topic_for(field:, scope:, **)
367
+ GraphQL::Subscriptions::Serialize.dump_recursive([scope, field.graphql_name])
368
+ end
369
+
370
+ def update(author_id: nil)
371
+ author_id.nil? || object.author_id.to_s == author_id.to_s ? object : NO_UPDATE
372
+ end
373
+ end
374
+ ```
375
+
376
+ PHP Lighthouse works this way by default: the topic is the field, every subscriber
377
+ is a candidate, and `filter` decides. The `Lighthouse::GraphQL::Subscription`
378
+ concern does the same and lets you choose, per class:
379
+
380
+ | | `topic_by :field` (the default) | `topic_by :arguments` |
381
+ | --- | --- | --- |
382
+ | A broadcast reaches | every subscriber, narrowed by `filter?` | the subscribers whose arguments equal the trigger's |
383
+ | Trigger arguments | none needed | `self.trigger_arguments(root, context)` must rebuild them |
384
+ | Condition on the subscriber | any Ruby, using their `context` and arguments | equality only |
385
+ | Cost of a broadcast | each subscriber's operation is evaluated, then filtered | only the matching subscribers' |
386
+
387
+ Take the field-wide topic when the condition is more than "the argument equals":
388
+ ownership, permissions, a list of ids, a score threshold. Take exact arguments
389
+ when there are many subscribers and the argument identifies a small group of them
390
+ (a chat room, a document). A `topic_by :arguments` class needs `trigger_arguments`
391
+ for a field with required arguments like a plain one, and is reported the same way.
392
+ (The concern's own `trigger_arguments` returns `{}`; define yours on the class.)
393
+
394
+ `broadcast: true` on the backend lets graphql-ruby evaluate identical
395
+ subscriptions once. Do not combine it with an `update` or `filter?` that reads the
396
+ subscriber's `context`: they would share one evaluation.
397
+
398
+ ### Queueing
399
+
400
+ A broadcast runs inside the mutation's request by default. `shouldQueue: true`, or
401
+ `Lighthouse::Config.queue_broadcasts = true` for every `@broadcast` that does not
402
+ say otherwise, enqueues an ActiveJob, `Lighthouse::GraphQL::BroadcastJob`, which
403
+ does the trigger:
404
+
405
+ ```ruby
406
+ Lighthouse.configure do |config|
407
+ config.queue_broadcasts = true
408
+ config.broadcast_queue = :subscriptions # default: ActiveJob's default queue
409
+ end
410
+ ```
411
+
412
+ The job carries the schema's name, the subscription's name, the trigger arguments,
413
+ the scope and the root; an Active Record model is serialized with GlobalID, any
414
+ other root must be something ActiveJob can serialize. A schema built by
415
+ `get_schema` and assigned to a constant (`AppSchema = ...`) has the name; an
416
+ anonymous one raises `Lighthouse::DefinitionError` when a queued broadcast needs
417
+ it. The `activejob` gem is optional: nothing loads it unless a broadcast is queued.
418
+
419
+ ## Triggering by hand
420
+
421
+ Any code can trigger a subscription, as in graphql-ruby, for what is not a
422
+ mutation:
423
+
424
+ ```ruby
425
+ AppSchema.subscriptions.trigger(:post_updated, {}, post)
426
+ AppSchema.subscriptions.trigger(:message_posted, { room: room.id.to_s }, message, scope: room.tenant_id)
427
+ ```
428
+
429
+ The arguments and scope are those the subscription's class would produce for its
430
+ topic (see [above](#topics-trigger-arguments-and-filtering)): none for a field
431
+ without arguments, or for the default `topic_by :field`.
432
+
433
+ ## Testing
434
+
435
+ `GraphQL::Subscriptions` is an interface of a handful of methods
436
+ (`write_subscription`, `read_subscription`, `delete_subscription`, `deliver`,
437
+ `execute_all`), so a spec can install a backend that keeps everything in memory:
438
+ the repository's own specs do, in `spec/support/in_memory_subscriptions.rb`.
439
+ graphql-ruby also ships `GraphQL::Testing::MockActionCable` for the real
440
+ ActionCable backend.
441
+
442
+ The repository's `example/` app is a complete setup: subscription classes under
443
+ `Subscriptions::BaseSubscription`,
444
+ `GraphqlChannel`, `ApplicationCable::Connection` with token authentication, and
445
+ specs that drive a mutation through `POST /graphql` and watch the update arrive on
446
+ an ActionCable subscriber (`example/spec/subscriptions/delivery_spec.rb`).
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+ require 'lighthouse-graphql'
5
+
6
+ # The base class graphql-ruby's convention puts in each namespace:
7
+ # Resolvers::BaseResolver, Mutations::BaseMutation and
8
+ # Subscriptions::BaseSubscription. The classes Lighthouse generates inherit from
9
+ # them, so what every class of a kind shares (a concern, graphql-ruby's base type
10
+ # hookups) is written once, in the base. graphql-ruby's `graphql:install` writes
11
+ # the first two the same way.
12
+ #
13
+ # A base that already exists is never replaced, not even with --force: it holds
14
+ # the app's own code, and one written by graphql-ruby works as it is.
15
+ module Lighthouse::Generators::BaseClasses
16
+ BASES = {
17
+ queries: {
18
+ name: 'BaseResolver',
19
+ source: <<~RUBY
20
+ # What every class resolving a Query field shares: Lighthouse resolves
21
+ # Query.latestPosts to Resolvers::LatestPosts by convention, and every such
22
+ # class inherits from this one. graphql-ruby's `graphql:install` writes the
23
+ # same class.
24
+ class BaseResolver < GraphQL::Schema::Resolver
25
+ end
26
+ RUBY
27
+ },
28
+ mutations: {
29
+ name: 'BaseMutation',
30
+ source: <<~RUBY
31
+ # What every class resolving a Mutation field shares: Lighthouse resolves
32
+ # Mutation.publishPost to Mutations::PublishPost by convention, and every such
33
+ # class inherits from this one. The SDL declares the arguments, so it is a
34
+ # plain GraphQL::Schema::Mutation; the GraphQL::Schema::RelayClassicMutation
35
+ # graphql-ruby's `graphql:install` writes (one `input:` argument) works too.
36
+ class BaseMutation < GraphQL::Schema::Mutation
37
+ end
38
+ RUBY
39
+ },
40
+ subscriptions: {
41
+ name: 'BaseSubscription',
42
+ source: <<~RUBY
43
+ # What every class resolving a Subscription field shares. The concern is PHP
44
+ # Lighthouse's model: every subscriber of a field gets every broadcast, and the
45
+ # class's `filter?` decides who receives it. A subclass that defines
46
+ # graphql-ruby's own `authorized?` or `update` keeps them, and `topic_by
47
+ # :arguments` brings back graphql-ruby's exact-argument matching.
48
+ class BaseSubscription < GraphQL::Schema::Subscription
49
+ include Lighthouse::GraphQL::Subscription
50
+ end
51
+ RUBY
52
+ }
53
+ }.freeze
54
+
55
+ private
56
+
57
+ # The base class name for `kind` (`BaseResolver`), or nil for a kind without one.
58
+ def base_class_name(kind)
59
+ BASES.dig(kind, :name)
60
+ end
61
+
62
+ # Write the base class of `kind` into `namespace`, unless its file exists.
63
+ def write_base_class(kind, namespace)
64
+ base = BASES[kind]
65
+ return unless base
66
+
67
+ path = base_class_path(kind, namespace)
68
+ if File.exist?(File.join(destination_root, path))
69
+ say_status :skip, "#{path} already exists", :yellow
70
+ else
71
+ create_file path, ruby_source(namespace.split('::'), base[:source])
72
+ end
73
+ end
74
+
75
+ def base_class_path(kind, namespace)
76
+ File.join('app/graphql', *namespace.split('::').map(&:underscore), "#{base_class_name(kind).underscore}.rb")
77
+ end
78
+
79
+ # Ruby source defining `body` inside the nested `modules`.
80
+ def ruby_source(modules, body)
81
+ lines = ['# frozen_string_literal: true', '']
82
+ modules.each_with_index { |mod, depth| lines << "#{' ' * depth}module #{mod}" }
83
+ body.each_line { |line| lines << (line.strip.empty? ? '' : "#{' ' * modules.size}#{line.chomp}") }
84
+ modules.size.times.reverse_each { |depth| lines << "#{' ' * depth}end" }
85
+ "#{lines.join("\n")}\n"
86
+ end
87
+ end
@@ -2,16 +2,25 @@
2
2
 
3
3
  require 'rails/generators'
4
4
  require 'lighthouse-graphql'
5
+ require_relative 'base_classes'
5
6
 
6
7
  # Base for the generators that scaffold the classes a schema refers to by
7
- # name — queries, mutations, builders and validators — each in its own
8
- # namespace (Config.namespaces) and with its own contract.
8
+ # name — resolvers, mutations, subscriptions, builders and validators — each in
9
+ # its own namespace (Config.namespaces) and with its own contract. A resolver,
10
+ # mutation or subscription inherits from its namespace's base class
11
+ # (Resolvers::BaseResolver, ...), written first when it is missing.
9
12
  class Lighthouse::Generators::ClassGenerator < Rails::Generators::NamedBase
13
+ include Lighthouse::Generators::BaseClasses
14
+
10
15
  hide!
11
16
 
12
17
  class_option :namespace, type: :string,
13
18
  desc: 'Namespace to generate into (default: the first of Config.namespaces for this kind)'
14
19
 
20
+ def create_base_class
21
+ write_base_class(kind, namespace_name)
22
+ end
23
+
15
24
  def create_class
16
25
  create_file File.join('app/graphql', *modules.map(&:underscore), "#{class_basename.underscore}.rb"), source
17
26
  end
@@ -22,7 +31,7 @@ class Lighthouse::Generators::ClassGenerator < Rails::Generators::NamedBase
22
31
 
23
32
  private
24
33
 
25
- # :queries, :mutations, :builders or :validators.
34
+ # :queries, :mutations, :subscriptions, :builders or :validators.
26
35
  def kind
27
36
  raise NotImplementedError
28
37
  end
@@ -52,10 +61,12 @@ class Lighthouse::Generators::ClassGenerator < Rails::Generators::NamedBase
52
61
  end
53
62
 
54
63
  def source
55
- lines = ['# frozen_string_literal: true', '']
56
- modules.each_with_index { |mod, depth| lines << "#{' ' * depth}module #{mod}" }
57
- body.each_line { |line| lines << (line.strip.empty? ? '' : "#{' ' * modules.size}#{line.chomp}") }
58
- modules.size.times.reverse_each { |depth| lines << "#{' ' * depth}end" }
59
- "#{lines.join("\n")}\n"
64
+ ruby_source(modules, body)
65
+ end
66
+
67
+ # The class a generated class inherits from: the namespace's base class,
68
+ # which Ruby finds from inside the namespace's modules.
69
+ def superclass_name
70
+ base_class_name(kind)
60
71
  end
61
72
  end
@@ -2,22 +2,26 @@
2
2
 
3
3
  require 'rails/generators'
4
4
  require 'lighthouse-graphql'
5
+ require_relative '../base_classes'
5
6
 
6
7
  # `rails generate lighthouse:install` — sets a Rails app up for Lighthouse:
7
- # the initializer, a starter SDL file, the schema class, a controller and
8
- # its route, plus the graphql-config file IDEs read. Rerunning it skips the
9
- # route, SDL and .gitignore entries that are already in place.
8
+ # the initializer, a starter SDL file, the schema class, the base classes of
9
+ # resolvers, mutations and subscriptions, a controller and its route, plus the
10
+ # graphql-config file IDEs read. Rerunning it skips the route, SDL, base classes
11
+ # and .gitignore entries that are already in place.
10
12
  #
11
13
  # It coexists with graphql-ruby's `graphql:install`, which writes the same
12
14
  # controller path and route: an existing schema class or controller is kept
13
15
  # (pass --force to replace it) instead of overwritten, so an app already
14
16
  # serving a graphql-ruby schema keeps doing so until it is pointed at ours.
15
17
  class Lighthouse::Generators::InstallGenerator < Rails::Generators::Base
18
+ include Lighthouse::Generators::BaseClasses
19
+
16
20
  CONTROLLER = 'app/controllers/graphql_controller.rb'
17
21
 
18
22
  source_root File.expand_path('templates', __dir__)
19
23
 
20
- desc 'Install Lighthouse: initializer, starter schema, schema class, controller, route and IDE config.'
24
+ desc 'Install Lighthouse: initializer, starter schema, schema class, base classes, controller, route and IDE config.'
21
25
 
22
26
  class_option :schema, type: :string, default: 'AppSchema',
23
27
  desc: 'Name of the schema constant to generate'
@@ -48,6 +52,12 @@ class Lighthouse::Generators::InstallGenerator < Rails::Generators::Base
48
52
  template 'schema.rb.tt', path
49
53
  end
50
54
 
55
+ # Resolvers::BaseResolver, Mutations::BaseMutation and Subscriptions::BaseSubscription,
56
+ # in the namespaces Config.namespaces names first (see BaseClasses).
57
+ def create_base_classes
58
+ BASES.each_key { |kind| write_base_class(kind, Lighthouse::Config.namespaces[kind].first) }
59
+ end
60
+
51
61
  def create_controller
52
62
  return if options[:skip_controller]
53
63
  return if (@kept_controller = keep_existing?(CONTROLLER))
@@ -93,8 +103,10 @@ class Lighthouse::Generators::InstallGenerator < Rails::Generators::Base
93
103
 
94
104
  Lighthouse is installed. Next steps:
95
105
  1. Describe your API in #{sdl_folder}/*.graphql (the starter schema assumes a User model).
96
- 2. Generate the IDE helper files: bin/rails lighthouse:ide_helper
97
- 3. #{serve}
106
+ 2. Resolve a field in Ruby when a directive is not enough: bin/rails g lighthouse:query LatestPosts
107
+ (its class inherits from Resolvers::BaseResolver; mutations and subscriptions have a base too)
108
+ 3. Generate the IDE helper files: bin/rails lighthouse:ide_helper
109
+ 4. #{serve}
98
110
  MSG
99
111
  end
100
112
 
@@ -113,6 +125,11 @@ class Lighthouse::Generators::InstallGenerator < Rails::Generators::Base
113
125
  options[:schema].camelize
114
126
  end
115
127
 
128
+ # `Resolvers::BaseResolver`, for the schema class template.
129
+ def base_class_constant(kind)
130
+ "#{Lighthouse::Config.namespaces[kind].first}::#{base_class_name(kind)}"
131
+ end
132
+
116
133
  def sdl_folder
117
134
  options[:sdl_folder].delete_suffix('/')
118
135
  end