lighthouse-graphql 0.3.0 → 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 +44 -1
- data/README.md +11 -3
- data/docs/best-practices.md +16 -6
- data/docs/configuration.md +7 -6
- data/docs/directives.md +31 -6
- data/docs/events.md +3 -1
- data/docs/federation.md +3 -2
- data/docs/getting-started.md +52 -14
- data/docs/motivation.md +3 -2
- data/docs/resolvers.md +93 -19
- data/docs/security.md +210 -0
- data/docs/subscriptions.md +60 -37
- 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/initializer.rb.tt +7 -6
- 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 +21 -16
- data/lib/lighthouse/complexity.rb +174 -0
- data/lib/lighthouse/complexity_analyzer.rb +56 -0
- data/lib/lighthouse/config.rb +4 -3
- data/lib/lighthouse/directive_resolvers/convention_resolver.rb +14 -4
- data/lib/lighthouse/directive_resolvers/convert_empty_strings_resolver.rb +1 -1
- data/lib/lighthouse/directive_resolvers/subscription_resolver.rb +12 -4
- data/lib/lighthouse/directives/registry.rb +1 -0
- data/lib/lighthouse/events/execution_trace.rb +2 -1
- data/lib/lighthouse/events/lifecycle.rb +3 -1
- data/lib/lighthouse/field_resolver_stack.rb +8 -7
- data/lib/lighthouse/graphql/version.rb +1 -1
- data/lib/lighthouse/rb_lighthouse.rb +3 -0
- data/lib/lighthouse/resolver_bases.rb +61 -0
- data/lib/lighthouse/schema_factory.rb +0 -1
- data/lib/lighthouse/schema_generator.rb +15 -7
- data/lib/lighthouse/sdl_loader.rb +0 -2
- data/lib/lighthouse/support/model_resolver.rb +1 -1
- data/sig/lighthouse/graphql.rbs +29 -0
- metadata +6 -1
data/docs/security.md
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# Query Complexity & Depth
|
|
2
|
+
|
|
3
|
+
A single GraphQL request can ask for a lot: a page of 1,000 posts, each with
|
|
4
|
+
its 1,000 comments, each with its author. graphql-ruby can refuse such a query
|
|
5
|
+
**before it runs**. It scores the query from the fields it selects, then
|
|
6
|
+
compares the score with a limit you set. Lighthouse doesn't add a scorer of its
|
|
7
|
+
own. It gives your SDL's fields their cost, and graphql-ruby's analyzers
|
|
8
|
+
(`GraphQL::Analysis::MaxQueryComplexity`, `MaxQueryDepth`) do the rest. PHP
|
|
9
|
+
Lighthouse works the same way on top of webonyx's validation rules.
|
|
10
|
+
|
|
11
|
+
## Setting the limits
|
|
12
|
+
|
|
13
|
+
The limits are graphql-ruby's own schema settings. Set them on the schema
|
|
14
|
+
Lighthouse builds:
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
# app/graphql/app_schema.rb
|
|
18
|
+
AppSchema = Lighthouse::GraphQL::RbLightHouse.get_schema(options: { using: { GraphQL::Dataloader => {} } })
|
|
19
|
+
|
|
20
|
+
AppSchema.max_complexity(300) # refuse queries that score over 300
|
|
21
|
+
AppSchema.max_depth(12) # refuse queries nested deeper than 12 fields
|
|
22
|
+
AppSchema.disable_introspection_entry_points if Rails.env.production?
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
You can also set them on a base schema class you hand to `get_schema` with
|
|
26
|
+
`options: { base_schema: MyBaseSchema }`.
|
|
27
|
+
|
|
28
|
+
A query over the limit isn't executed. Its response holds only the error:
|
|
29
|
+
|
|
30
|
+
```json
|
|
31
|
+
{ "errors": [{ "message": "Query has complexity of 3001, which exceeds max complexity of 300" }] }
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
To give one request a different limit, pass it to `execute`:
|
|
35
|
+
`AppSchema.execute(query, max_complexity: 1000)`.
|
|
36
|
+
|
|
37
|
+
## How a query is scored
|
|
38
|
+
|
|
39
|
+
Each selected field adds its cost, and a field's cost includes the cost of the
|
|
40
|
+
fields selected inside it (its *child complexity*). By default, graphql-ruby
|
|
41
|
+
counts **1 per field**:
|
|
42
|
+
|
|
43
|
+
```graphql
|
|
44
|
+
{ posts { id title author { name } } } # posts 1 + id 1 + title 1 + author 1 + name 1 = 5
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Lighthouse changes that default in three places:
|
|
48
|
+
|
|
49
|
+
| Field | Cost |
|
|
50
|
+
| --- | --- |
|
|
51
|
+
| Paginated: `@paginate`, or `@hasMany` / `@belongsToMany` / `@morphMany` / `@morphToMany` given `type:` | `1 + child complexity × first` |
|
|
52
|
+
| Carrying `@complexity` | `1 + child complexity × first`, or the `complexity` of the analyzer it names (below) |
|
|
53
|
+
| Resolved by a `GraphQL::Schema::Resolver` class, by convention (`Query.search` → `Resolvers::Search`) or through `@field` | the `complexity` that class declares (default: `1 + child complexity`) |
|
|
54
|
+
|
|
55
|
+
Paginated fields scale by `first`, which already holds the page size when the
|
|
56
|
+
client leaves it out (the directive's `defaultCount`, else
|
|
57
|
+
`pagination_default_count`). That way, asking for 1,000 items costs 1,000 times
|
|
58
|
+
as much as asking for one:
|
|
59
|
+
|
|
60
|
+
```graphql
|
|
61
|
+
{ posts(first: 100) { data { id title } } } # 1 + (data 1 + id 1 + title 1) × 100 = 301
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
For a connection (`type: CONNECTION`) the same rule applies, multiplying
|
|
65
|
+
everything under `edges` and `pageInfo`, as PHP Lighthouse does. It replaces
|
|
66
|
+
graphql-ruby's own connection scaling, which knows nothing about `defaultCount`.
|
|
67
|
+
|
|
68
|
+
## `@complexity`
|
|
69
|
+
|
|
70
|
+
Customize a field's cost, or attach a graphql-ruby analyzer to the schema.
|
|
71
|
+
|
|
72
|
+
```graphql
|
|
73
|
+
type Query {
|
|
74
|
+
"1 + child complexity × first, as on a paginated field."
|
|
75
|
+
search(first: Int, term: String!): [Post!]! @complexity
|
|
76
|
+
|
|
77
|
+
"What Analyzers::PostsComplexity says."
|
|
78
|
+
posts(includeFullText: Boolean): [Post!]! @all @complexity(resolver: "PostsComplexity")
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
| Argument | Meaning |
|
|
83
|
+
| --- | --- |
|
|
84
|
+
| `resolver: String` | A `GraphQL::Analysis::Analyzer` subclass, looked up in `namespaces[:analyzers]` (`Analyzers::PostsComplexity`). A name containing `::` is taken as written. |
|
|
85
|
+
|
|
86
|
+
Without `resolver:`, the field costs `1 + child complexity × first` (`first` is 1
|
|
87
|
+
when the field has no such argument).
|
|
88
|
+
|
|
89
|
+
### The analyzer
|
|
90
|
+
|
|
91
|
+
`resolver:` names a graphql-ruby analyzer, written as graphql-ruby's
|
|
92
|
+
[analysis guide](https://graphql-ruby.org/queries/ast_analysis.html) writes it.
|
|
93
|
+
Lighthouse installs it on the schema, as `query_analyzer Analyzers::PostsComplexity`
|
|
94
|
+
would, once however many fields name it. Like every graphql-ruby analyzer, it
|
|
95
|
+
runs on **every query**, not only those that select the field:
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
# app/graphql/analyzers/agent_bot_analyzer.rb
|
|
99
|
+
class Analyzers::AgentBotAnalyzer < GraphQL::Analysis::Analyzer
|
|
100
|
+
def analyze? = subject.context[:current_user].is_a?(AgentBot)
|
|
101
|
+
|
|
102
|
+
def on_enter_field(node, _parent, visitor)
|
|
103
|
+
@refused = true if visitor.parent_type_definition == visitor.query.schema.mutation
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def result
|
|
107
|
+
GraphQL::AnalysisError.new('Bots may not run mutations') if @refused
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
A field naming a plain analyzer costs what a bare `@complexity` gives. An
|
|
113
|
+
analyzer that can't be found, or a class that isn't a
|
|
114
|
+
`GraphQL::Analysis::Analyzer`, stops the schema from building
|
|
115
|
+
(`Lighthouse::DefinitionError`).
|
|
116
|
+
|
|
117
|
+
### Giving the field its cost: `Lighthouse::GraphQL::ComplexityAnalyzer`
|
|
118
|
+
|
|
119
|
+
Include the concern, and the analyzer's `complexity` becomes the cost of the
|
|
120
|
+
fields naming it. It's the `complexity` graphql-ruby gives a field or a
|
|
121
|
+
`GraphQL::Schema::Resolver`: a number, added to the cost of the field's
|
|
122
|
+
selection, or a proc called with the query's context, the field's arguments
|
|
123
|
+
(snake_case Symbol keys) and the child complexity, returning the whole cost.
|
|
124
|
+
|
|
125
|
+
As with resolvers and mutations, the concern goes once in a base class, and each
|
|
126
|
+
analyzer that prices a field inherits from it:
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
# app/graphql/analyzers/base_complexity.rb
|
|
130
|
+
class Analyzers::BaseComplexity < GraphQL::Analysis::QueryComplexity
|
|
131
|
+
include Lighthouse::GraphQL::ComplexityAnalyzer
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# app/graphql/analyzers/posts_complexity.rb
|
|
135
|
+
class Analyzers::PostsComplexity < Analyzers::BaseComplexity
|
|
136
|
+
complexity ->(_context, args, child_complexity) { child_complexity * (args[:include_full_text] ? 3 : 2) }
|
|
137
|
+
end
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
`complexity 5` gives a fixed cost: 5 plus the selection's. A subclass inherits
|
|
141
|
+
its superclass's `complexity`. Without one, the field costs what a bare
|
|
142
|
+
`@complexity` gives. The cost wins over the pagination default.
|
|
143
|
+
|
|
144
|
+
The class is still an analyzer. With a `result` of its own, it runs on every
|
|
145
|
+
query like any other, so one class can price a field and act on the whole query,
|
|
146
|
+
for example refusing a guest's query over a smaller budget:
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
class Analyzers::PostsComplexity < Analyzers::BaseComplexity
|
|
150
|
+
complexity 10
|
|
151
|
+
|
|
152
|
+
def result
|
|
153
|
+
cost = max_possible_complexity
|
|
154
|
+
return if query.context[:current_user] || cost <= 200
|
|
155
|
+
|
|
156
|
+
GraphQL::AnalysisError.new("Guests may run queries up to 200, this one costs #{cost}")
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Without a `result` of its own, it only gives costs, and graphql-ruby skips it
|
|
162
|
+
during analysis.
|
|
163
|
+
|
|
164
|
+
## Resolver classes declare their own cost
|
|
165
|
+
|
|
166
|
+
A root field without a resolving directive resolves to a
|
|
167
|
+
`GraphQL::Schema::Resolver` by convention (see [resolvers.md](resolvers.md)).
|
|
168
|
+
That class's `complexity` applies, exactly as with graphql-ruby's
|
|
169
|
+
`field :search, resolver: Resolvers::Search`:
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
class Resolvers::Search < Resolvers::BaseResolver
|
|
173
|
+
complexity ->(_ctx, args, child_complexity) { args[:limit] * child_complexity }
|
|
174
|
+
|
|
175
|
+
def resolve(term:, limit:) = Post.search(term).limit(limit)
|
|
176
|
+
end
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
The same goes for a class named by `@field(resolver:)`, and for a mutation
|
|
180
|
+
class. An explicit `@complexity` on the field takes precedence over the class.
|
|
181
|
+
|
|
182
|
+
## Reading the score
|
|
183
|
+
|
|
184
|
+
`BuildExtensionsResponse` carries the score as `query_complexity`. It's the same
|
|
185
|
+
number `max_complexity` is checked against, so you can report it to clients or
|
|
186
|
+
to your metrics:
|
|
187
|
+
|
|
188
|
+
```ruby
|
|
189
|
+
Lighthouse::Events.listen(Lighthouse::Events::BuildExtensionsResponse) do |event|
|
|
190
|
+
Lighthouse::Events::ExtensionsResponse.new('complexity', event.query_complexity)
|
|
191
|
+
end
|
|
192
|
+
# => { "data" => {...}, "extensions" => { "complexity" => 301 } }
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
The query is scored only while something listens to that event, and a query
|
|
196
|
+
that failed validation is never scored (`query_complexity` is `nil`). A query
|
|
197
|
+
refused for going over `max_complexity` still carries its score, so you can log
|
|
198
|
+
what was refused. See
|
|
199
|
+
[events.md](events.md#execution-events).
|
|
200
|
+
|
|
201
|
+
To score a query yourself, run graphql-ruby's analyzer:
|
|
202
|
+
|
|
203
|
+
```ruby
|
|
204
|
+
query = GraphQL::Query.new(AppSchema, '{ posts(first: 10) { data { id } } }')
|
|
205
|
+
GraphQL::Analysis.analyze_query(query, [GraphQL::Analysis::QueryComplexity]).first # => 21
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
`QueryComplexity` logs a warning unless the schema chooses an algorithm with
|
|
209
|
+
`AppSchema.complexity_cost_calculation_mode(:future)`. `max_complexity` and
|
|
210
|
+
`query_complexity` always use the `:future` algorithm.
|
data/docs/subscriptions.md
CHANGED
|
@@ -6,9 +6,12 @@ happens. Lighthouse builds on [graphql-ruby's subscriptions](https://graphql-rub
|
|
|
6
6
|
and keeps the shape of [PHP Lighthouse's](https://lighthouse-php.com/master/subscriptions/getting-started.html):
|
|
7
7
|
|
|
8
8
|
- a `Subscription` field is declared in the SDL, and **resolves by convention** to a class;
|
|
9
|
-
- that class
|
|
10
|
-
|
|
11
|
-
|
|
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?`;
|
|
12
15
|
- a mutation announces what it did with [`@broadcast`](#broadcast-from-a-mutation).
|
|
13
16
|
|
|
14
17
|
```graphql
|
|
@@ -23,10 +26,10 @@ type Mutation {
|
|
|
23
26
|
|
|
24
27
|
```ruby
|
|
25
28
|
# app/graphql/subscriptions/post_updated.rb (rails generate lighthouse:subscription PostUpdated)
|
|
26
|
-
class Subscriptions::PostUpdated <
|
|
27
|
-
#
|
|
28
|
-
def
|
|
29
|
-
|
|
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]
|
|
30
33
|
end
|
|
31
34
|
end
|
|
32
35
|
```
|
|
@@ -94,12 +97,14 @@ type Subscription {
|
|
|
94
97
|
}
|
|
95
98
|
```
|
|
96
99
|
|
|
97
|
-
For anything more, write the class (
|
|
98
|
-
the
|
|
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
|
|
99
103
|
`Subscriptions::PostUpdated`, looked up in `Config.namespaces[:subscriptions]`
|
|
100
104
|
(default `['Subscriptions']`) each time it is needed, so code reloading works.
|
|
101
105
|
`rails generate lighthouse:subscription PostUpdated` writes it. It inherits from
|
|
102
|
-
`GraphQL::Schema::Subscription`: anything you
|
|
106
|
+
`Subscriptions::BaseSubscription`, a `GraphQL::Schema::Subscription`: anything you
|
|
107
|
+
know from graphql-ruby applies.
|
|
103
108
|
|
|
104
109
|
Like a query or a mutation class, it declares neither the arguments nor the return
|
|
105
110
|
type: the SDL does. The field's arguments arrive as `snake_case` keywords.
|
|
@@ -109,7 +114,10 @@ type: the SDL does. The field's arguments arrive as `snake_case` keywords.
|
|
|
109
114
|
graphql-ruby calls the class twice in a subscription's life: once for the
|
|
110
115
|
**subscription request** (the client's `subscription { ... }`), and then once for
|
|
111
116
|
**each update** (`@broadcast`, or `schema.subscriptions.trigger`). All hooks are
|
|
112
|
-
optional.
|
|
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.
|
|
113
121
|
|
|
114
122
|
| Hook | Called | Default |
|
|
115
123
|
| --- | --- | --- |
|
|
@@ -135,7 +143,7 @@ optional.
|
|
|
135
143
|
field comes back `null`, with no error.
|
|
136
144
|
|
|
137
145
|
```ruby
|
|
138
|
-
class Subscriptions::OrderShipped <
|
|
146
|
+
class Subscriptions::OrderShipped < Subscriptions::BaseSubscription
|
|
139
147
|
def authorized?(**)
|
|
140
148
|
raise GraphQL::ExecutionError, 'Sign in to follow your orders.' unless context[:current_user]
|
|
141
149
|
|
|
@@ -145,8 +153,6 @@ class Subscriptions::OrderShipped < GraphQL::Schema::Subscription
|
|
|
145
153
|
def update(**)
|
|
146
154
|
object.user_id == context[:current_user].id ? object : NO_UPDATE
|
|
147
155
|
end
|
|
148
|
-
|
|
149
|
-
def self.trigger_arguments(_order, _context) = {}
|
|
150
156
|
end
|
|
151
157
|
```
|
|
152
158
|
|
|
@@ -164,22 +170,32 @@ If you know PHP Lighthouse's `GraphQLSubscription`, this is where its methods we
|
|
|
164
170
|
One real difference is the **topic**. PHP Lighthouse puts every subscriber of a
|
|
165
171
|
field on one topic and `filter` decides who is updated. graphql-ruby's default
|
|
166
172
|
topic includes the arguments, so a trigger reaches only the subscribers whose
|
|
167
|
-
arguments *equal* its own.
|
|
168
|
-
`
|
|
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).
|
|
169
176
|
|
|
170
|
-
##
|
|
177
|
+
## The subscription concern
|
|
171
178
|
|
|
172
|
-
|
|
173
|
-
it and `resolve_payload` for the result,
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
its arguments as one would in graphql-ruby (or leaves them to the SDL).
|
|
177
|
-
`rails generate lighthouse:subscription PostUpdated --lighthouse` writes it:
|
|
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:
|
|
178
183
|
|
|
179
184
|
```ruby
|
|
180
|
-
|
|
181
|
-
|
|
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:
|
|
182
196
|
|
|
197
|
+
```ruby
|
|
198
|
+
class Subscriptions::PostUpdated < Subscriptions::BaseSubscription
|
|
183
199
|
def filter?(post, author: nil)
|
|
184
200
|
author.nil? || post.author_id.to_s == author.to_s
|
|
185
201
|
end
|
|
@@ -205,17 +221,19 @@ optional.
|
|
|
205
221
|
[broadcast section](#topics-trigger-arguments-and-filtering) explain.
|
|
206
222
|
- Including it in a class that is not a `GraphQL::Schema::Subscription` raises a
|
|
207
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.
|
|
208
227
|
- **Precedence.** The concern sits between your class and
|
|
209
|
-
`GraphQL::Schema::Subscription
|
|
228
|
+
`GraphQL::Schema::Subscription` (it is in the base), so what the class defines
|
|
229
|
+
itself wins. A class
|
|
210
230
|
with its own `update` (or `authorized?`) is called instead of the concern's, and
|
|
211
231
|
its `filter?`, `resolve_payload` (or `authorize?`) are not, unless it calls
|
|
212
232
|
`super`. `subscribe`, `unsubscribe`, `subscription_scope` and `ready?` are
|
|
213
233
|
untouched, and combine freely with the hooks.
|
|
214
234
|
|
|
215
235
|
```ruby
|
|
216
|
-
class Subscriptions::OrderShipped <
|
|
217
|
-
include Lighthouse::GraphQL::Subscription
|
|
218
|
-
|
|
236
|
+
class Subscriptions::OrderShipped < Subscriptions::BaseSubscription
|
|
219
237
|
def authorize?(**)
|
|
220
238
|
context[:current_user].present?
|
|
221
239
|
end
|
|
@@ -251,8 +269,9 @@ type Subscription {
|
|
|
251
269
|
|
|
252
270
|
The name is looked up in `Config.namespaces[:subscriptions]` like the conventional
|
|
253
271
|
one, so `class: "PostUpdated"` finds `Subscriptions::PostUpdated`, and a name with
|
|
254
|
-
`::` is used as it is. A class that does not exist,
|
|
255
|
-
`GraphQL::Schema::Subscription`,
|
|
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`.
|
|
256
275
|
`@guard`, `@can*` and the other wrapping directives work on a subscription field
|
|
257
276
|
as on any other.
|
|
258
277
|
|
|
@@ -303,7 +322,8 @@ arguments (a Hash, `{}` by default) for the `root` a mutation returned, and
|
|
|
303
322
|
`subscription_scope` (graphql-ruby's way to give each tenant or user their own
|
|
304
323
|
stream).
|
|
305
324
|
|
|
306
|
-
|
|
325
|
+
When the topic includes the arguments (a class with `topic_by :arguments`, or under
|
|
326
|
+
a base that does not include the concern):
|
|
307
327
|
|
|
308
328
|
- **A field without arguments works out of the box.** Every subscriber's arguments
|
|
309
329
|
are `{}`, and so is the trigger's.
|
|
@@ -317,7 +337,9 @@ With a plain `GraphQL::Schema::Subscription`:
|
|
|
317
337
|
```
|
|
318
338
|
|
|
319
339
|
```ruby
|
|
320
|
-
class Subscriptions::MessagePosted <
|
|
340
|
+
class Subscriptions::MessagePosted < Subscriptions::BaseSubscription
|
|
341
|
+
topic_by :arguments
|
|
342
|
+
|
|
321
343
|
# The subscribers of `messagePosted(room: "<id>")`.
|
|
322
344
|
def self.trigger_arguments(message, _context) = { room: message.room_id.to_s }
|
|
323
345
|
end
|
|
@@ -336,11 +358,11 @@ With a plain `GraphQL::Schema::Subscription`:
|
|
|
336
358
|
those subscribers are updated, so give such a class a `trigger_arguments` too if
|
|
337
359
|
subscribers pass the argument.
|
|
338
360
|
- **To have one trigger reach subscribers with different arguments** (the argument
|
|
339
|
-
is a filter, not an identity), give the class one topic per
|
|
340
|
-
graphql-ruby documents, and let `update` return `NO_UPDATE`:
|
|
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`:
|
|
341
363
|
|
|
342
364
|
```ruby
|
|
343
|
-
class Subscriptions::PostPublished <
|
|
365
|
+
class Subscriptions::PostPublished < Subscriptions::BaseSubscription # a base without the concern
|
|
344
366
|
def self.topic_for(field:, scope:, **)
|
|
345
367
|
GraphQL::Subscriptions::Serialize.dump_recursive([scope, field.graphql_name])
|
|
346
368
|
end
|
|
@@ -417,7 +439,8 @@ the repository's own specs do, in `spec/support/in_memory_subscriptions.rb`.
|
|
|
417
439
|
graphql-ruby also ships `GraphQL::Testing::MockActionCable` for the real
|
|
418
440
|
ActionCable backend.
|
|
419
441
|
|
|
420
|
-
The repository's `example/` app is a complete setup:
|
|
442
|
+
The repository's `example/` app is a complete setup: subscription classes under
|
|
443
|
+
`Subscriptions::BaseSubscription`,
|
|
421
444
|
`GraphqlChannel`, `ApplicationCable::Connection` with token authentication, and
|
|
422
445
|
specs that drive a mutation through `POST /graphql` and watch the update arrive on
|
|
423
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
|
|
@@ -12,15 +12,16 @@ Lighthouse.configure do |config|
|
|
|
12
12
|
|
|
13
13
|
# Where classes named by the schema are looked up, by kind. A Query or Mutation
|
|
14
14
|
# field without a resolving directive resolves by convention: Query.latestPosts
|
|
15
|
-
# to
|
|
16
|
-
# to Mutations::PublishPost (a
|
|
15
|
+
# to Resolvers::LatestPosts (a Resolvers::BaseResolver), Mutation.publishPost
|
|
16
|
+
# to Mutations::PublishPost (a Mutations::BaseMutation). @builder classes
|
|
17
17
|
# come from :builders and @validator contracts from :validators. A Subscription
|
|
18
|
-
# field resolves to Subscriptions::PostUpdated (a
|
|
19
|
-
#
|
|
18
|
+
# field resolves to Subscriptions::PostUpdated (a Subscriptions::BaseSubscription),
|
|
19
|
+
# @event(dispatch:) classes come from :events, and the graphql-ruby analyzers
|
|
20
|
+
# @complexity(resolver:) names from :analyzers. Generate them with
|
|
20
21
|
# `rails g lighthouse:query|mutation|subscription|builder|validator Name`.
|
|
21
22
|
# config.namespaces = {
|
|
22
|
-
# queries: ['
|
|
23
|
-
# builders: ['Builders'], validators: ['Validators'], events: ['Events']
|
|
23
|
+
# queries: ['Resolvers'], mutations: ['Mutations'], subscriptions: ['Subscriptions'],
|
|
24
|
+
# builders: ['Builders'], validators: ['Validators'], events: ['Events'], analyzers: ['Analyzers']
|
|
24
25
|
# }
|
|
25
26
|
|
|
26
27
|
# Namespaces tried, in order, for models (@all(model: "User")) and for
|