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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +99 -1
- data/README.md +22 -3
- data/docs/best-practices.md +16 -6
- data/docs/caching.md +1 -1
- data/docs/configuration.md +74 -5
- data/docs/custom-directives.md +11 -1
- data/docs/directives.md +333 -2
- data/docs/events.md +203 -0
- data/docs/federation.md +3 -2
- data/docs/getting-started.md +90 -23
- data/docs/motivation.md +3 -2
- data/docs/mutations.md +15 -0
- data/docs/resolvers.md +95 -19
- data/docs/security.md +210 -0
- data/docs/subscriptions.md +446 -0
- data/lib/generators/lighthouse/base_classes.rb +87 -0
- data/lib/generators/lighthouse/class_generator.rb +19 -8
- data/lib/generators/lighthouse/install/install_generator.rb +23 -6
- data/lib/generators/lighthouse/install/templates/graphql_controller.rb.tt +7 -45
- data/lib/generators/lighthouse/install/templates/initializer.rb.tt +34 -5
- data/lib/generators/lighthouse/install/templates/schema.rb.tt +14 -2
- data/lib/generators/lighthouse/mutation_generator.rb +5 -4
- data/lib/generators/lighthouse/query_generator.rb +5 -4
- data/lib/generators/lighthouse/subscription_generator.rb +100 -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/complexity.rb +174 -0
- data/lib/lighthouse/complexity_analyzer.rb +56 -0
- data/lib/lighthouse/config.rb +56 -14
- data/lib/lighthouse/controller.rb +131 -0
- data/lib/lighthouse/directive_resolvers/broadcast_resolver.rb +31 -0
- data/lib/lighthouse/directive_resolvers/convention_resolver.rb +14 -4
- 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 +76 -0
- data/lib/lighthouse/directives/arguments/soft_deletes.rb +112 -0
- data/lib/lighthouse/directives/registry.rb +15 -6
- data/lib/lighthouse/directives/soft_deletes_directive.rb +123 -0
- data/lib/lighthouse/events/execution_trace.rb +52 -0
- data/lib/lighthouse/events/lifecycle.rb +148 -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 +28 -7
- 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 +23 -0
- data/lib/lighthouse/resolver_bases.rb +61 -0
- data/lib/lighthouse/schema_cache.rb +12 -5
- data/lib/lighthouse/schema_factory.rb +0 -1
- data/lib/lighthouse/schema_generator.rb +42 -9
- data/lib/lighthouse/sdl_loader.rb +0 -2
- data/lib/lighthouse/subscription.rb +120 -0
- data/lib/lighthouse/support/model_resolver.rb +1 -1
- 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/events.rbs +122 -0
- data/sig/lighthouse/graphql.rbs +62 -0
- data/sig/lighthouse.rbs +52 -1
- 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 —
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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,
|
|
8
|
-
#
|
|
9
|
-
#
|
|
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.
|
|
97
|
-
|
|
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
|