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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +77 -1
- data/README.md +16 -1
- data/docs/caching.md +1 -1
- data/docs/configuration.md +68 -0
- data/docs/custom-directives.md +11 -1
- data/docs/directives.md +306 -0
- data/docs/events.md +201 -0
- data/docs/getting-started.md +38 -9
- data/docs/motivation.md +184 -0
- data/docs/mutations.md +15 -0
- data/docs/resolvers.md +13 -1
- data/docs/subscriptions.md +423 -0
- data/lib/generators/lighthouse/builder_generator.rb +1 -1
- data/lib/generators/lighthouse/install/templates/graphql_controller.rb.tt +7 -45
- data/lib/generators/lighthouse/install/templates/initializer.rb.tt +31 -3
- data/lib/generators/lighthouse/subscription_generator.rb +95 -0
- data/lib/lighthouse/broadcast_job.rb +14 -0
- data/lib/lighthouse/broadcasting.rb +143 -0
- data/lib/lighthouse/cache_control.rb +5 -2
- data/lib/lighthouse/config.rb +53 -12
- data/lib/lighthouse/controller.rb +131 -0
- data/lib/lighthouse/directive_resolvers/broadcast_resolver.rb +31 -0
- data/lib/lighthouse/directive_resolvers/convert_empty_strings_resolver.rb +134 -0
- data/lib/lighthouse/directive_resolvers/event_resolver.rb +37 -0
- data/lib/lighthouse/directive_resolvers/model_mutation_resolver.rb +28 -13
- data/lib/lighthouse/directive_resolvers/soft_delete_resolvers.rb +66 -0
- data/lib/lighthouse/directive_resolvers/subscription_resolver.rb +68 -0
- data/lib/lighthouse/directives/arguments/soft_deletes.rb +112 -0
- data/lib/lighthouse/directives/registry.rb +14 -6
- data/lib/lighthouse/directives/soft_deletes_directive.rb +123 -0
- data/lib/lighthouse/events/execution_trace.rb +51 -0
- data/lib/lighthouse/events/lifecycle.rb +146 -0
- data/lib/lighthouse/events/schema_build.rb +44 -0
- data/lib/lighthouse/events.rb +76 -0
- data/lib/lighthouse/feature_directive.rb +137 -0
- data/lib/lighthouse/field_resolver_stack.rb +22 -2
- data/lib/lighthouse/graphql/version.rb +1 -1
- data/lib/lighthouse/namespaces.rb +1 -0
- data/lib/lighthouse/persisted_queries/batch.rb +34 -0
- data/lib/lighthouse/persisted_queries.rb +17 -4
- data/lib/lighthouse/rb_lighthouse.rb +20 -0
- data/lib/lighthouse/schema_cache.rb +12 -5
- data/lib/lighthouse/schema_generator.rb +27 -2
- data/lib/lighthouse/subscription.rb +120 -0
- data/lib/lighthouse/support/soft_deletes.rb +164 -0
- data/lib/lighthouse/tasks/lighthouse.rake +10 -0
- data/sig/lighthouse/controller.rbs +30 -0
- data/sig/lighthouse/directives.rbs +158 -0
- data/sig/lighthouse/events.rbs +122 -0
- data/sig/lighthouse/graphql.rbs +111 -0
- data/sig/lighthouse.rbs +193 -0
- 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
|
-
|
|
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
|
-
|
|
30
|
-
end
|
|
12
|
+
lighthouse_schema <%= schema_name %>
|
|
31
13
|
|
|
32
14
|
private
|
|
33
15
|
|
|
34
|
-
#
|
|
35
|
-
|
|
36
|
-
|
|
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.
|
|
18
|
-
#
|
|
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'],
|
|
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
|