lighthouse-graphql 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -1
  3. data/README.md +16 -1
  4. data/docs/caching.md +1 -1
  5. data/docs/configuration.md +68 -0
  6. data/docs/custom-directives.md +11 -1
  7. data/docs/directives.md +306 -0
  8. data/docs/events.md +201 -0
  9. data/docs/getting-started.md +38 -9
  10. data/docs/motivation.md +184 -0
  11. data/docs/mutations.md +15 -0
  12. data/docs/resolvers.md +13 -1
  13. data/docs/subscriptions.md +423 -0
  14. data/lib/generators/lighthouse/builder_generator.rb +1 -1
  15. data/lib/generators/lighthouse/install/templates/graphql_controller.rb.tt +7 -45
  16. data/lib/generators/lighthouse/install/templates/initializer.rb.tt +31 -3
  17. data/lib/generators/lighthouse/subscription_generator.rb +95 -0
  18. data/lib/lighthouse/broadcast_job.rb +14 -0
  19. data/lib/lighthouse/broadcasting.rb +143 -0
  20. data/lib/lighthouse/cache_control.rb +5 -2
  21. data/lib/lighthouse/config.rb +53 -12
  22. data/lib/lighthouse/controller.rb +131 -0
  23. data/lib/lighthouse/directive_resolvers/broadcast_resolver.rb +31 -0
  24. data/lib/lighthouse/directive_resolvers/convert_empty_strings_resolver.rb +134 -0
  25. data/lib/lighthouse/directive_resolvers/event_resolver.rb +37 -0
  26. data/lib/lighthouse/directive_resolvers/model_mutation_resolver.rb +28 -13
  27. data/lib/lighthouse/directive_resolvers/soft_delete_resolvers.rb +66 -0
  28. data/lib/lighthouse/directive_resolvers/subscription_resolver.rb +68 -0
  29. data/lib/lighthouse/directives/arguments/soft_deletes.rb +112 -0
  30. data/lib/lighthouse/directives/registry.rb +14 -6
  31. data/lib/lighthouse/directives/soft_deletes_directive.rb +123 -0
  32. data/lib/lighthouse/events/execution_trace.rb +51 -0
  33. data/lib/lighthouse/events/lifecycle.rb +146 -0
  34. data/lib/lighthouse/events/schema_build.rb +44 -0
  35. data/lib/lighthouse/events.rb +76 -0
  36. data/lib/lighthouse/feature_directive.rb +137 -0
  37. data/lib/lighthouse/field_resolver_stack.rb +22 -2
  38. data/lib/lighthouse/graphql/version.rb +1 -1
  39. data/lib/lighthouse/namespaces.rb +1 -0
  40. data/lib/lighthouse/persisted_queries/batch.rb +34 -0
  41. data/lib/lighthouse/persisted_queries.rb +17 -4
  42. data/lib/lighthouse/rb_lighthouse.rb +20 -0
  43. data/lib/lighthouse/schema_cache.rb +12 -5
  44. data/lib/lighthouse/schema_generator.rb +27 -2
  45. data/lib/lighthouse/subscription.rb +120 -0
  46. data/lib/lighthouse/support/soft_deletes.rb +164 -0
  47. data/lib/lighthouse/tasks/lighthouse.rake +10 -0
  48. data/sig/lighthouse/controller.rbs +30 -0
  49. data/sig/lighthouse/directives.rbs +158 -0
  50. data/sig/lighthouse/events.rbs +122 -0
  51. data/sig/lighthouse/graphql.rbs +111 -0
  52. data/sig/lighthouse.rbs +193 -0
  53. metadata +29 -2
@@ -0,0 +1,423 @@
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 is a plain graphql-ruby [`GraphQL::Schema::Subscription`](https://graphql-ruby.org/subscriptions/subscription_classes.html),
10
+ as a query is a `GraphQL::Schema::Resolver` and a mutation a `GraphQL::Schema::Mutation`
11
+ (see [resolvers.md](resolvers.md));
12
+ - a mutation announces what it did with [`@broadcast`](#broadcast-from-a-mutation).
13
+
14
+ ```graphql
15
+ type Subscription {
16
+ postUpdated: Post
17
+ }
18
+
19
+ type Mutation {
20
+ updatePost(id: ID!, title: String): Post! @update @broadcast(subscription: "postUpdated")
21
+ }
22
+ ```
23
+
24
+ ```ruby
25
+ # app/graphql/subscriptions/post_updated.rb (rails generate lighthouse:subscription PostUpdated)
26
+ class Subscriptions::PostUpdated < GraphQL::Schema::Subscription
27
+ # Runs for each update: skip the subscribers who may not see this post.
28
+ def update(**)
29
+ object.published? || object.author == context[:current_user] ? object : NO_UPDATE
30
+ end
31
+ end
32
+ ```
33
+
34
+ ## Enabling a backend
35
+
36
+ graphql-ruby delivers subscriptions through a *backend* that stores who subscribed
37
+ and sends the updates: ActionCable (bundled), or a gem such as Pusher or Ably.
38
+ Lighthouse needs none of its own; give the schema one. `get_schema` passes the
39
+ `using:` option to graphql-ruby, as `use` does in a class-based schema:
40
+
41
+ ```ruby
42
+ # app/graphql/app_schema.rb
43
+ AppSchema = Lighthouse::GraphQL::RbLightHouse.get_schema(
44
+ options: { using: { GraphQL::Subscriptions::ActionCableSubscriptions => {} } }
45
+ )
46
+ ```
47
+
48
+ Options of the backend go in the hash (`{ ActionCableSubscriptions => { namespace: 'my-app' } }`).
49
+ Assign the schema to a constant: a [queued](#queueing) broadcast finds it by name.
50
+
51
+ Then add the channel graphql-ruby documents, which runs the operation and keeps
52
+ the client's subscription ids ([the full example](https://graphql-ruby.org/subscriptions/action_cable_implementation.html)):
53
+
54
+ ```ruby
55
+ class GraphqlChannel < ApplicationCable::Channel
56
+ def subscribed
57
+ @subscription_ids = []
58
+ end
59
+
60
+ def execute(data)
61
+ result = AppSchema.execute(
62
+ data['query'],
63
+ variables: data['variables'] || {},
64
+ operation_name: data['operationName'],
65
+ context: { channel: self, current_user: current_user }
66
+ )
67
+ @subscription_ids << result.context[:subscription_id] if result.context[:subscription_id]
68
+ transmit({ result: result.to_h, more: result.subscription? })
69
+ end
70
+
71
+ def unsubscribed
72
+ @subscription_ids.each { |id| AppSchema.subscriptions.delete_subscription(id) }
73
+ end
74
+ end
75
+ ```
76
+
77
+ Use a real ActionCable adapter (Redis, PostgreSQL, Solid Cable), not `async`: the
78
+ broadcast is often triggered from a job or a console, another process than the
79
+ one holding the sockets. The root a broadcast carries travels through
80
+ ActionCable's serialization, so an Active Record model must be serializable with
81
+ GlobalID. A Rails app that loads ActiveJob has that; one that picks its railties
82
+ (an API-only `config/application.rb`) needs `require 'global_id/railtie'`, or the
83
+ subscribers would receive the model's `inspect` string.
84
+
85
+ ## Defining subscription fields
86
+
87
+ A field of the `Subscription` type needs nothing but its SDL, and then
88
+ **every broadcast reaches every subscriber, unchanged**: `@broadcast` hands the
89
+ mutation's result to the subscription, which resolves the field with it.
90
+
91
+ ```graphql
92
+ type Subscription {
93
+ postCreated: Post
94
+ }
95
+ ```
96
+
97
+ For anything more, write the class (a field with no class of its own behaves like
98
+ the concern's field-wide topic with no `filter?`). `Subscription.postUpdated` resolves to
99
+ `Subscriptions::PostUpdated`, looked up in `Config.namespaces[:subscriptions]`
100
+ (default `['Subscriptions']`) each time it is needed, so code reloading works.
101
+ `rails generate lighthouse:subscription PostUpdated` writes it. It inherits from
102
+ `GraphQL::Schema::Subscription`: anything you know from graphql-ruby applies.
103
+
104
+ Like a query or a mutation class, it declares neither the arguments nor the return
105
+ type: the SDL does. The field's arguments arrive as `snake_case` keywords.
106
+
107
+ ### The lifecycle
108
+
109
+ graphql-ruby calls the class twice in a subscription's life: once for the
110
+ **subscription request** (the client's `subscription { ... }`), and then once for
111
+ **each update** (`@broadcast`, or `schema.subscriptions.trigger`). All hooks are
112
+ optional.
113
+
114
+ | Hook | Called | Default |
115
+ | --- | --- | --- |
116
+ | `authorized?(**args)` | for the request **and** for every update | `true` |
117
+ | `subscribe(**args)` | for the request: its return value answers the client at once | `:no_response`: nothing yet |
118
+ | `update(**args)` | for each update: its return value is what the field resolves to | `object`, the broadcast root |
119
+ | `unsubscribe` | call it to end the subscription from the server | |
120
+ | `self.subscription_scope` | declare it to give each tenant or user their own stream | none |
121
+ | `self.topic_for(arguments:, field:, scope:)` | the stream a subscription joins and a trigger addresses | the field, its scope and its arguments |
122
+
123
+ - **`args`** are the arguments the client *subscribed* with, in `snake_case`.
124
+ `object` is the broadcast root in `update`, and `context` is the *subscriber's*:
125
+ graphql-ruby stores it with the subscription and replays it for each update, so
126
+ `context[:current_user]` is whoever subscribed.
127
+ - **`update`** returns `NO_UPDATE` (`GraphQL::Schema::Subscription::NO_UPDATE`) to
128
+ skip this subscriber for this broadcast, or another value to send it instead of
129
+ `object`.
130
+ - **`subscribe`** is where to send an initial payload (the current state, say);
131
+ return `:no_response`, or leave it out, to send nothing until the first update.
132
+ - **`authorized?`** runs again for each update, with the subscriber's stored
133
+ context. Raising a `GraphQL::ExecutionError` refuses the request and tells the
134
+ client why; nothing is registered. Returning `false` refuses it silently: the
135
+ field comes back `null`, with no error.
136
+
137
+ ```ruby
138
+ class Subscriptions::OrderShipped < GraphQL::Schema::Subscription
139
+ def authorized?(**)
140
+ raise GraphQL::ExecutionError, 'Sign in to follow your orders.' unless context[:current_user]
141
+
142
+ true
143
+ end
144
+
145
+ def update(**)
146
+ object.user_id == context[:current_user].id ? object : NO_UPDATE
147
+ end
148
+
149
+ def self.trigger_arguments(_order, _context) = {}
150
+ end
151
+ ```
152
+
153
+ If you know PHP Lighthouse's `GraphQLSubscription`, this is where its methods went:
154
+
155
+ | PHP Lighthouse | graphql-ruby |
156
+ | --- | --- |
157
+ | `authorize(Subscriber, Request): bool` | `authorized?`, which also runs for each update (check `context.query.subscription_update?` to tell them apart) |
158
+ | `filter(Subscriber, $root): bool` | `update` returning `NO_UPDATE` |
159
+ | `resolve($root, $args, $context, $info)` | `update`'s return value |
160
+ | `encodeTopic` / `decodeTopic` | `self.topic_for(arguments:, field:, scope:)` |
161
+ | the subscriber's `$args` | the keywords of `subscribe` and `update` |
162
+ | `Subscription::broadcast($name, $root)` | `@broadcast`, or `schema.subscriptions.trigger` |
163
+
164
+ One real difference is the **topic**. PHP Lighthouse puts every subscriber of a
165
+ field on one topic and `filter` decides who is updated. graphql-ruby's default
166
+ topic includes the arguments, so a trigger reaches only the subscribers whose
167
+ arguments *equal* its own. That is why `@broadcast` asks a plain class for its
168
+ `trigger_arguments`; see [below](#topics-trigger-arguments-and-filtering).
169
+
170
+ ## Lighthouse's subscription concern
171
+
172
+ If you prefer PHP Lighthouse's model, with one topic per field, `filter?` to narrow
173
+ it and `resolve_payload` for the result, include `Lighthouse::GraphQL::Subscription`
174
+ in the class, as `include Lighthouse::GraphQL::Controller` gives a controller its
175
+ `execute`. The class is still a plain `GraphQL::Schema::Subscription`, and declares
176
+ its arguments as one would in graphql-ruby (or leaves them to the SDL).
177
+ `rails generate lighthouse:subscription PostUpdated --lighthouse` writes it:
178
+
179
+ ```ruby
180
+ class Subscriptions::PostUpdated < GraphQL::Schema::Subscription
181
+ include Lighthouse::GraphQL::Subscription
182
+
183
+ def filter?(post, author: nil)
184
+ author.nil? || post.author_id.to_s == author.to_s
185
+ end
186
+ end
187
+ ```
188
+
189
+ The concern maps the hooks of `GraphQLSubscription` onto graphql-ruby's. All are
190
+ optional.
191
+
192
+ | PHP Lighthouse | Concern | Maps onto | Default |
193
+ | --- | --- | --- | --- |
194
+ | `authorize(Subscriber, Request): bool` | `authorize?(**args)` | `authorized?`, on the subscription request only | `true` |
195
+ | `filter(Subscriber, $root): bool` | `filter?(root, **args)` | `update` returns `NO_UPDATE` when it is false | `true` |
196
+ | `resolve($root, $args, $context, $info)` | `resolve_payload(root, **args)` | the value `update` returns | `root` |
197
+ | `encodeTopic` / `decodeTopic` | `self.topic_for(arguments:, field:, scope:)` | graphql-ruby's topic | the field and its scope |
198
+
199
+ - `authorize?` returning false refuses the subscription: the client gets
200
+ `Unauthorized subscription request.` and nothing is registered. Raise a
201
+ `GraphQL::ExecutionError` to say more. Unlike `authorized?`, it is not called again for the updates.
202
+ - `filter?` is where a subscriber is turned down for one broadcast.
203
+ - The concern also adds the class methods `topic_by`, `topic_for`,
204
+ `trigger_arguments` and `trigger_scope`, which `topic_by` and the
205
+ [broadcast section](#topics-trigger-arguments-and-filtering) explain.
206
+ - Including it in a class that is not a `GraphQL::Schema::Subscription` raises a
207
+ `Lighthouse::DefinitionError`.
208
+ - **Precedence.** The concern sits between your class and
209
+ `GraphQL::Schema::Subscription`, so what the class defines itself wins. A class
210
+ with its own `update` (or `authorized?`) is called instead of the concern's, and
211
+ its `filter?`, `resolve_payload` (or `authorize?`) are not, unless it calls
212
+ `super`. `subscribe`, `unsubscribe`, `subscription_scope` and `ready?` are
213
+ untouched, and combine freely with the hooks.
214
+
215
+ ```ruby
216
+ class Subscriptions::OrderShipped < GraphQL::Schema::Subscription
217
+ include Lighthouse::GraphQL::Subscription
218
+
219
+ def authorize?(**)
220
+ context[:current_user].present?
221
+ end
222
+
223
+ def filter?(order, **)
224
+ order.user_id == context[:current_user].id
225
+ end
226
+
227
+ def resolve_payload(order, **)
228
+ order.decorate
229
+ end
230
+ end
231
+ ```
232
+
233
+ Its topic is the **field** (and its scope), not its arguments: a broadcast reaches
234
+ every subscriber, and `filter?` decides. `topic_by :arguments` (inherited by
235
+ subclasses) turns graphql-ruby's exact matching back on; see below.
236
+
237
+ ## `@subscription`
238
+
239
+ A class that does not follow the naming convention, or lives elsewhere, is named
240
+ with `@subscription(class:)`:
241
+
242
+ ```graphql
243
+ directive @subscription(class: String!) on FIELD_DEFINITION
244
+ ```
245
+
246
+ ```graphql
247
+ type Subscription {
248
+ postUpdated(author: ID!): Post @subscription(class: "Blog::PostUpdatedSubscription")
249
+ }
250
+ ```
251
+
252
+ The name is looked up in `Config.namespaces[:subscriptions]` like the conventional
253
+ one, so `class: "PostUpdated"` finds `Subscriptions::PostUpdated`, and a name with
254
+ `::` is used as it is. A class that does not exist, or is not a
255
+ `GraphQL::Schema::Subscription`, raises `Lighthouse::DefinitionError`.
256
+ `@guard`, `@can*` and the other wrapping directives work on a subscription field
257
+ as on any other.
258
+
259
+ ## Broadcast from a mutation
260
+
261
+ `@broadcast` triggers a subscription after its field has resolved:
262
+
263
+ ```graphql
264
+ directive @broadcast(subscription: String!, shouldQueue: Boolean) repeatable on FIELD_DEFINITION
265
+ ```
266
+
267
+ ```graphql
268
+ type Mutation {
269
+ createPost(input: CreatePostInput! @spread): Post! @create @broadcast(subscription: "postCreated")
270
+ updatePost(input: UpdatePostInput! @spread): Post!
271
+ @update
272
+ @broadcast(subscription: "postUpdated", shouldQueue: false)
273
+ @broadcast(subscription: "postsOfAuthor")
274
+ }
275
+ ```
276
+
277
+ - The **result of the field is the root** the subscribers' updates are resolved
278
+ from (`object` in `update`). Each subscriber's operation runs against it, and
279
+ `update` (or `filter?`) can turn a subscriber down.
280
+ - It fires **once the field resolved successfully**: a mutation that raised, was
281
+ rejected by `@guard`, `@can*` or `@validator`, failed model validations, or
282
+ returned `nil` broadcasts nothing. It runs after `@create`/`@update` saved,
283
+ so the transaction they open has committed.
284
+ - It is **repeatable**: every occurrence triggers its own subscription, once.
285
+ - `subscription:` is the field's name in the `Subscription` type (`snake_case`
286
+ works too). One that does not exist is a `Lighthouse::DefinitionError` when the
287
+ schema is built.
288
+ - A field returning a **list** (`@createMany`) broadcasts the list as one root. A
289
+ subscription field of a list type, or a `resolve_payload` that picks from it,
290
+ handles it.
291
+ - A schema with **no subscriptions backend** reports a `Lighthouse::DefinitionError`
292
+ saying how to install one, through `Config.on_error` (raised in a test
293
+ environment, logged otherwise); the mutation itself has succeeded.
294
+
295
+ ### Topics, trigger arguments and filtering
296
+
297
+ graphql-ruby sends an update only to the subscribers whose arguments **equal** the
298
+ arguments of the trigger: `trigger(:post_updated, { author: 1 }, post)` reaches
299
+ `postUpdated(author: 1)` and no one else. `@broadcast` has to know what to trigger
300
+ with, so it asks the class: `self.trigger_arguments(root, context)` returns the
301
+ arguments (a Hash, `{}` by default) for the `root` a mutation returned, and
302
+ `self.trigger_scope(root, context)` the `scope:` for a class declaring
303
+ `subscription_scope` (graphql-ruby's way to give each tenant or user their own
304
+ stream).
305
+
306
+ With a plain `GraphQL::Schema::Subscription`:
307
+
308
+ - **A field without arguments works out of the box.** Every subscriber's arguments
309
+ are `{}`, and so is the trigger's.
310
+ - **A field with arguments needs `trigger_arguments`**, returning the value of every
311
+ argument that identifies the group of subscribers:
312
+
313
+ ```graphql
314
+ type Subscription {
315
+ messagePosted(room: ID!): Message
316
+ }
317
+ ```
318
+
319
+ ```ruby
320
+ class Subscriptions::MessagePosted < GraphQL::Schema::Subscription
321
+ # The subscribers of `messagePosted(room: "<id>")`.
322
+ def self.trigger_arguments(message, _context) = { room: message.room_id.to_s }
323
+ end
324
+ ```
325
+
326
+ - **Arguments and no `trigger_arguments` is a misconfiguration**, and it fails
327
+ silently in graphql-ruby: a trigger of `{}` equals no subscriber's arguments, so
328
+ nobody is ever updated. Lighthouse reports it the first time such a field is
329
+ broadcast, with the field, the class and the arguments, as a
330
+ `Lighthouse::DefinitionError` through `Config.on_error` (raised in a test
331
+ environment, logged otherwise), and triggers nothing. It is reported when the
332
+ broadcast happens rather than when the schema is built, because the class is only
333
+ looked up then (so that code reloading works), and only when an argument is
334
+ **required**: that is certain to reach nobody. A field whose arguments are all
335
+ optional is not reported, since its argument-less subscribers do match `{}`; only
336
+ those subscribers are updated, so give such a class a `trigger_arguments` too if
337
+ subscribers pass the argument.
338
+ - **To have one trigger reach subscribers with different arguments** (the argument
339
+ is a filter, not an identity), give the class one topic per field, as
340
+ graphql-ruby documents, and let `update` return `NO_UPDATE`:
341
+
342
+ ```ruby
343
+ class Subscriptions::PostPublished < GraphQL::Schema::Subscription
344
+ def self.topic_for(field:, scope:, **)
345
+ GraphQL::Subscriptions::Serialize.dump_recursive([scope, field.graphql_name])
346
+ end
347
+
348
+ def update(author_id: nil)
349
+ author_id.nil? || object.author_id.to_s == author_id.to_s ? object : NO_UPDATE
350
+ end
351
+ end
352
+ ```
353
+
354
+ PHP Lighthouse works this way by default: the topic is the field, every subscriber
355
+ is a candidate, and `filter` decides. The `Lighthouse::GraphQL::Subscription`
356
+ concern does the same and lets you choose, per class:
357
+
358
+ | | `topic_by :field` (the default) | `topic_by :arguments` |
359
+ | --- | --- | --- |
360
+ | A broadcast reaches | every subscriber, narrowed by `filter?` | the subscribers whose arguments equal the trigger's |
361
+ | Trigger arguments | none needed | `self.trigger_arguments(root, context)` must rebuild them |
362
+ | Condition on the subscriber | any Ruby, using their `context` and arguments | equality only |
363
+ | Cost of a broadcast | each subscriber's operation is evaluated, then filtered | only the matching subscribers' |
364
+
365
+ Take the field-wide topic when the condition is more than "the argument equals":
366
+ ownership, permissions, a list of ids, a score threshold. Take exact arguments
367
+ when there are many subscribers and the argument identifies a small group of them
368
+ (a chat room, a document). A `topic_by :arguments` class needs `trigger_arguments`
369
+ for a field with required arguments like a plain one, and is reported the same way.
370
+ (The concern's own `trigger_arguments` returns `{}`; define yours on the class.)
371
+
372
+ `broadcast: true` on the backend lets graphql-ruby evaluate identical
373
+ subscriptions once. Do not combine it with an `update` or `filter?` that reads the
374
+ subscriber's `context`: they would share one evaluation.
375
+
376
+ ### Queueing
377
+
378
+ A broadcast runs inside the mutation's request by default. `shouldQueue: true`, or
379
+ `Lighthouse::Config.queue_broadcasts = true` for every `@broadcast` that does not
380
+ say otherwise, enqueues an ActiveJob, `Lighthouse::GraphQL::BroadcastJob`, which
381
+ does the trigger:
382
+
383
+ ```ruby
384
+ Lighthouse.configure do |config|
385
+ config.queue_broadcasts = true
386
+ config.broadcast_queue = :subscriptions # default: ActiveJob's default queue
387
+ end
388
+ ```
389
+
390
+ The job carries the schema's name, the subscription's name, the trigger arguments,
391
+ the scope and the root; an Active Record model is serialized with GlobalID, any
392
+ other root must be something ActiveJob can serialize. A schema built by
393
+ `get_schema` and assigned to a constant (`AppSchema = ...`) has the name; an
394
+ anonymous one raises `Lighthouse::DefinitionError` when a queued broadcast needs
395
+ it. The `activejob` gem is optional: nothing loads it unless a broadcast is queued.
396
+
397
+ ## Triggering by hand
398
+
399
+ Any code can trigger a subscription, as in graphql-ruby, for what is not a
400
+ mutation:
401
+
402
+ ```ruby
403
+ AppSchema.subscriptions.trigger(:post_updated, {}, post)
404
+ AppSchema.subscriptions.trigger(:message_posted, { room: room.id.to_s }, message, scope: room.tenant_id)
405
+ ```
406
+
407
+ The arguments and scope are those the subscription's class would produce for its
408
+ topic (see [above](#topics-trigger-arguments-and-filtering)): none for a field
409
+ without arguments, or for the default `topic_by :field`.
410
+
411
+ ## Testing
412
+
413
+ `GraphQL::Subscriptions` is an interface of a handful of methods
414
+ (`write_subscription`, `read_subscription`, `delete_subscription`, `deliver`,
415
+ `execute_all`), so a spec can install a backend that keeps everything in memory:
416
+ the repository's own specs do, in `spec/support/in_memory_subscriptions.rb`.
417
+ graphql-ruby also ships `GraphQL::Testing::MockActionCable` for the real
418
+ ActionCable backend.
419
+
420
+ The repository's `example/` app is a complete setup: plain subscription classes,
421
+ `GraphqlChannel`, `ApplicationCable::Connection` with token authentication, and
422
+ specs that drive a mutation through `POST /graphql` and watch the update arrive on
423
+ an ActionCable subscriber (`example/spec/subscriptions/delivery_spec.rb`).
@@ -15,7 +15,7 @@ class Lighthouse::Generators::BuilderGenerator < Lighthouse::Generators::ClassGe
15
15
  <<~RUBY
16
16
  # Applied by @builder(class: "#{class_name}") before the field resolves:
17
17
  # receives the relation, the field's arguments and the GraphQL context, and
18
- # returns the relation to use.
18
+ # returns the relation to use. Its type is Lighthouse::_Builder.
19
19
  class #{class_basename}
20
20
  def self.call(relation, args, context)
21
21
  relation
@@ -1,57 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Serves the schema: StartRequest/EndRequest events, persisted queries, batching,
4
+ # variables, Cache-Control. See docs/getting-started.md.
3
5
  class GraphqlController < ApplicationController
4
6
  # If this endpoint is called from outside this domain, null the session
5
7
  # instead of failing CSRF verification:
6
8
  # protect_from_forgery with: :null_session
7
9
 
8
- def execute
9
- # PersistedQueries.execute resolves Apollo-style persisted queries when
10
- # config.persisted_queries is on, and otherwise runs params[:query] as is.
11
- result = Lighthouse::GraphQL::PersistedQueries.execute(
12
- <%= schema_name %>,
13
- query: params[:query],
14
- extensions: params[:extensions],
15
- variables: prepare_variables(params[:variables]),
16
- operation_name: params[:operationName],
17
- context: {
18
- # Read by @guard and the @can* directives — see config/initializers/lighthouse.rb.
19
- # current_user: current_user
20
- }
21
- )
22
- # The Cache-Control header the selected fields' @cacheControl hints allow
23
- # (a no-op when the schema has none).
24
- Lighthouse::GraphQL::CacheControl.apply(response, result)
25
- render json: result
26
- rescue StandardError => e
27
- raise e unless Rails.env.development?
10
+ include Lighthouse::GraphQL::Controller
28
11
 
29
- handle_error_in_development(e)
30
- end
12
+ lighthouse_schema <%= schema_name %>
31
13
 
32
14
  private
33
15
 
34
- # Variables arrive as a JSON string (GET or form posts) or as parameters.
35
- def prepare_variables(variables_param)
36
- case variables_param
37
- when String
38
- variables_param.present? ? JSON.parse(variables_param) || {} : {}
39
- when Hash
40
- variables_param
41
- when ActionController::Parameters
42
- variables_param.to_unsafe_hash
43
- when nil
44
- {}
45
- else
46
- raise ArgumentError, "Unexpected parameter: #{variables_param}"
47
- end
48
- end
49
-
50
- def handle_error_in_development(error)
51
- logger.error(error.message)
52
- logger.error(error.backtrace.join("\n"))
53
-
54
- render json: { errors: [{ message: error.message, backtrace: error.backtrace }], data: {} },
55
- status: :internal_server_error
56
- end
16
+ # Read by @guard and the @can* directives — see config/initializers/lighthouse.rb.
17
+ # Defaults to { current_user: current_user } when the controller has a current_user.
18
+ # def graphql_context = { current_user: current_user }
57
19
  end
@@ -14,10 +14,13 @@ Lighthouse.configure do |config|
14
14
  # field without a resolving directive resolves by convention: Query.latestPosts
15
15
  # to Queries::LatestPosts (a GraphQL::Schema::Resolver), Mutation.publishPost
16
16
  # to Mutations::PublishPost (a GraphQL::Schema::Mutation). @builder classes
17
- # come from :builders and @validator contracts from :validators. Generate them
18
- # with `rails g lighthouse:query|mutation|builder|validator Name`.
17
+ # come from :builders and @validator contracts from :validators. A Subscription
18
+ # field resolves to Subscriptions::PostUpdated (a GraphQL::Schema::Subscription),
19
+ # and @event(dispatch:) classes come from :events. Generate them with
20
+ # `rails g lighthouse:query|mutation|subscription|builder|validator Name`.
19
21
  # config.namespaces = {
20
- # queries: ['Queries'], mutations: ['Mutations'], builders: ['Builders'], validators: ['Validators']
22
+ # queries: ['Queries'], mutations: ['Mutations'], subscriptions: ['Subscriptions'],
23
+ # builders: ['Builders'], validators: ['Validators'], events: ['Events']
21
24
  # }
22
25
 
23
26
  # Namespaces tried, in order, for models (@all(model: "User")) and for
@@ -31,6 +34,19 @@ Lighthouse.configure do |config|
31
34
  # Run @create/@update/@createMany/@updateMany in a database transaction.
32
35
  # config.transactional_mutations = true
33
36
 
37
+ # Treat "" as null on every field's inputs, as if each carried
38
+ # @convertEmptyStringsToNull. Read when the schema is built.
39
+ # config.convert_empty_strings_to_null = false
40
+
41
+ # What "trashed" means for @softDeletes, @trashed, @restore and @forceDelete.
42
+ # By default each model is inspected: paranoia, then discard, then a deleted_at column.
43
+ # config.soft_deletes = Lighthouse::Support::SoftDeletes::Column.new(:archived_at)
44
+
45
+ # Run @broadcast's subscription triggers in an ActiveJob (a directive's
46
+ # shouldQueue: overrides it), and the queue they go to (nil: the default one).
47
+ # config.queue_broadcasts = false
48
+ # config.broadcast_queue = nil
49
+
34
50
  # --- For large schemas and high traffic (all off by default) ---------------
35
51
  # Cache the built schema document in tmp/cache, keyed by the SDL and
36
52
  # everything that shapes it; warm it at deploy with `bin/rails lighthouse:cache`.
@@ -56,6 +72,11 @@ Lighthouse.configure do |config|
56
72
  # config.authenticated_user = ->(ctx) { ctx[:current_user] }
57
73
  # config.policy_user = ->(ctx) { ctx[:pundit_user] || ctx[:current_user] }
58
74
  # config.authorizer = ->(user, record, ability, args = []) { Pundit.policy!(user, record).public_send("#{ability}?", *args) }
75
+
76
+ # Feature flags read by @feature (Flipper by default): the actor a flag is
77
+ # checked for, and the check itself.
78
+ # config.feature_actor = ->(ctx) { ctx[:current_user] }
79
+ # config.feature_checker = ->(name, actor, _ctx) { Flipper.enabled?(name.to_sym, actor) }
59
80
  end
60
81
 
61
82
  # Register app-defined directives and federation reference resolvers inside
@@ -65,3 +86,10 @@ end
65
86
  # Lighthouse::DirectiveRegistry.register(Directives::Upcase)
66
87
  # Lighthouse::ReferenceResolver.register('User') { |ref, _ctx| User.find_by(id: ref['id']) }
67
88
  # end
89
+
90
+ # Listen to Lighthouse's request, schema-build and execution events. A listener
91
+ # may return a value where the event says so, as BuildExtensionsResponse does.
92
+ # See docs/events.md.
93
+ # Lighthouse::Events.listen(Lighthouse::Events::EndExecution) do |event|
94
+ # Rails.logger.info("[GraphQL] #{event.result.to_h['errors']&.size.to_i} errors")
95
+ # end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'class_generator'
4
+
5
+ # `rails generate lighthouse:subscription PostUpdated` — a
6
+ # GraphQL::Schema::Subscription that `Subscription.postUpdated` resolves to by
7
+ # convention. With `--lighthouse`, the class also includes
8
+ # Lighthouse::GraphQL::Subscription (PHP Lighthouse's authorize?/filter?/resolve_payload hooks).
9
+ class Lighthouse::Generators::SubscriptionGenerator < Lighthouse::Generators::ClassGenerator
10
+ desc 'Generate a subscription class (GraphQL::Schema::Subscription) in app/graphql/subscriptions.'
11
+
12
+ class_option :lighthouse, type: :boolean, default: false,
13
+ desc: "Include Lighthouse::GraphQL::Subscription: PHP Lighthouse's authorize?/filter?/resolve_payload hooks"
14
+
15
+ private
16
+
17
+ def kind = :subscriptions
18
+
19
+ def body
20
+ options[:lighthouse] ? lighthouse_body : plain_body
21
+ end
22
+
23
+ def plain_body
24
+ <<~RUBY
25
+ # Resolves Subscription.#{field_name}. Keyword arguments are the field's arguments in
26
+ # snake_case; `object` and `context` are available. `context` is the subscriber's:
27
+ # graphql-ruby stores it with the subscription and replays it for every update.
28
+ class #{class_basename} < GraphQL::Schema::Subscription
29
+ # Runs for the subscription request and again for every update. Raise a
30
+ # GraphQL::ExecutionError to refuse with a message (false refuses silently).
31
+ def authorized?(**args)
32
+ true
33
+ end
34
+
35
+ # The subscription request. Its return value answers the client right away;
36
+ # the default returns nothing (:no_response).
37
+ #
38
+ # def subscribe(**args)
39
+ # :no_response
40
+ # end
41
+
42
+ # Each update; `object` is what the mutation broadcast (@broadcast passes its
43
+ # result). The default returns `object`. Return NO_UPDATE to skip this
44
+ # subscriber, or another value to send that instead.
45
+ #
46
+ # def update(**args)
47
+ # return NO_UPDATE unless context[:current_user]
48
+ #
49
+ # object
50
+ # end
51
+
52
+ # What @broadcast triggers this subscription with, for the `root` a mutation
53
+ # returned. graphql-ruby updates only the subscribers whose arguments equal
54
+ # these, so return one value for every argument the field takes (`{}` for
55
+ # a field with none).
56
+ def self.trigger_arguments(root, context)
57
+ {}
58
+ end
59
+ end
60
+ RUBY
61
+ end
62
+
63
+ def lighthouse_body
64
+ <<~RUBY
65
+ # Resolves Subscription.#{field_name}. A graphql-ruby subscription with PHP Lighthouse's
66
+ # hooks: every subscriber of the field gets every broadcast, and `filter?` decides.
67
+ # All hooks are optional; `object` and `context` (the subscriber's) are available.
68
+ class #{class_basename} < GraphQL::Schema::Subscription
69
+ include Lighthouse::GraphQL::Subscription
70
+
71
+ # May this client subscribe? Keyword arguments are the field's arguments.
72
+ def authorize?(**args)
73
+ true
74
+ end
75
+
76
+ # Should this subscriber receive the broadcast of `root`?
77
+ def filter?(root, **args)
78
+ true
79
+ end
80
+
81
+ # What the update resolves the field with.
82
+ def resolve_payload(root, **args)
83
+ root
84
+ end
85
+ end
86
+ RUBY
87
+ end
88
+
89
+ def field_name = class_basename.camelize(:lower)
90
+
91
+ def usage
92
+ "Subscription.#{field_name} resolves to #{full_class_name} — add the field to your SDL, " \
93
+ "and trigger it from a mutation with @broadcast(subscription: \"#{field_name}\") or `schema.subscriptions.trigger`."
94
+ end
95
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_job'
4
+
5
+ # Triggers a subscription from a queue (@broadcast with shouldQueue, or
6
+ # Config.queue_broadcasts). Loaded on first use — the activejob gem is
7
+ # optional — so it is autoloaded rather than listed in rb_lighthouse.rb.
8
+ # The schema is passed by name, and the root (an Active Record model, say)
9
+ # through ActiveJob's serialization. (A gem has no ApplicationJob to inherit from.)
10
+ class Lighthouse::GraphQL::BroadcastJob < ActiveJob::Base # rubocop:disable Rails/ApplicationJob
11
+ def perform(schema_name, subscription, trigger_arguments, root, scope = nil)
12
+ Lighthouse::GraphQL::Broadcasting.trigger(schema_name.constantize, subscription, trigger_arguments, root, scope)
13
+ end
14
+ end