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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +44 -1
  3. data/README.md +11 -3
  4. data/docs/best-practices.md +16 -6
  5. data/docs/configuration.md +7 -6
  6. data/docs/directives.md +31 -6
  7. data/docs/events.md +3 -1
  8. data/docs/federation.md +3 -2
  9. data/docs/getting-started.md +52 -14
  10. data/docs/motivation.md +3 -2
  11. data/docs/resolvers.md +93 -19
  12. data/docs/security.md +210 -0
  13. data/docs/subscriptions.md +60 -37
  14. data/lib/generators/lighthouse/base_classes.rb +87 -0
  15. data/lib/generators/lighthouse/class_generator.rb +19 -8
  16. data/lib/generators/lighthouse/install/install_generator.rb +23 -6
  17. data/lib/generators/lighthouse/install/templates/initializer.rb.tt +7 -6
  18. data/lib/generators/lighthouse/install/templates/schema.rb.tt +14 -2
  19. data/lib/generators/lighthouse/mutation_generator.rb +5 -4
  20. data/lib/generators/lighthouse/query_generator.rb +5 -4
  21. data/lib/generators/lighthouse/subscription_generator.rb +21 -16
  22. data/lib/lighthouse/complexity.rb +174 -0
  23. data/lib/lighthouse/complexity_analyzer.rb +56 -0
  24. data/lib/lighthouse/config.rb +4 -3
  25. data/lib/lighthouse/directive_resolvers/convention_resolver.rb +14 -4
  26. data/lib/lighthouse/directive_resolvers/convert_empty_strings_resolver.rb +1 -1
  27. data/lib/lighthouse/directive_resolvers/subscription_resolver.rb +12 -4
  28. data/lib/lighthouse/directives/registry.rb +1 -0
  29. data/lib/lighthouse/events/execution_trace.rb +2 -1
  30. data/lib/lighthouse/events/lifecycle.rb +3 -1
  31. data/lib/lighthouse/field_resolver_stack.rb +8 -7
  32. data/lib/lighthouse/graphql/version.rb +1 -1
  33. data/lib/lighthouse/rb_lighthouse.rb +3 -0
  34. data/lib/lighthouse/resolver_bases.rb +61 -0
  35. data/lib/lighthouse/schema_factory.rb +0 -1
  36. data/lib/lighthouse/schema_generator.rb +15 -7
  37. data/lib/lighthouse/sdl_loader.rb +0 -2
  38. data/lib/lighthouse/support/model_resolver.rb +1 -1
  39. data/sig/lighthouse/graphql.rbs +29 -0
  40. 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.
@@ -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 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));
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 < 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
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 (a field with no class of its own behaves like
98
- the concern's field-wide topic with no `filter?`). `Subscription.postUpdated` resolves to
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 know from graphql-ruby applies.
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 < GraphQL::Schema::Subscription
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. That is why `@broadcast` asks a plain class for its
168
- `trigger_arguments`; see [below](#topics-trigger-arguments-and-filtering).
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
- ## Lighthouse's subscription concern
177
+ ## The subscription concern
171
178
 
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:
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
- class Subscriptions::PostUpdated < GraphQL::Schema::Subscription
181
- include Lighthouse::GraphQL::Subscription
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`, so what the class defines itself wins. A class
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 < GraphQL::Schema::Subscription
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, or is not a
255
- `GraphQL::Schema::Subscription`, raises `Lighthouse::DefinitionError`.
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
- With a plain `GraphQL::Schema::Subscription`:
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 < GraphQL::Schema::Subscription
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 field, as
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 < GraphQL::Schema::Subscription
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: plain subscription classes,
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 — queries, mutations, builders and validators — each in its own
8
- # namespace (Config.namespaces) and with its own contract.
8
+ # name — resolvers, mutations, subscriptions, builders and validators — each in
9
+ # its own namespace (Config.namespaces) and with its own contract. A resolver,
10
+ # mutation or subscription inherits from its namespace's base class
11
+ # (Resolvers::BaseResolver, ...), written first when it is missing.
9
12
  class Lighthouse::Generators::ClassGenerator < Rails::Generators::NamedBase
13
+ include Lighthouse::Generators::BaseClasses
14
+
10
15
  hide!
11
16
 
12
17
  class_option :namespace, type: :string,
13
18
  desc: 'Namespace to generate into (default: the first of Config.namespaces for this kind)'
14
19
 
20
+ def create_base_class
21
+ write_base_class(kind, namespace_name)
22
+ end
23
+
15
24
  def create_class
16
25
  create_file File.join('app/graphql', *modules.map(&:underscore), "#{class_basename.underscore}.rb"), source
17
26
  end
@@ -22,7 +31,7 @@ class Lighthouse::Generators::ClassGenerator < Rails::Generators::NamedBase
22
31
 
23
32
  private
24
33
 
25
- # :queries, :mutations, :builders or :validators.
34
+ # :queries, :mutations, :subscriptions, :builders or :validators.
26
35
  def kind
27
36
  raise NotImplementedError
28
37
  end
@@ -52,10 +61,12 @@ class Lighthouse::Generators::ClassGenerator < Rails::Generators::NamedBase
52
61
  end
53
62
 
54
63
  def source
55
- lines = ['# frozen_string_literal: true', '']
56
- modules.each_with_index { |mod, depth| lines << "#{' ' * depth}module #{mod}" }
57
- body.each_line { |line| lines << (line.strip.empty? ? '' : "#{' ' * modules.size}#{line.chomp}") }
58
- modules.size.times.reverse_each { |depth| lines << "#{' ' * depth}end" }
59
- "#{lines.join("\n")}\n"
64
+ ruby_source(modules, body)
65
+ end
66
+
67
+ # The class a generated class inherits from: the namespace's base class,
68
+ # which Ruby finds from inside the namespace's modules.
69
+ def superclass_name
70
+ base_class_name(kind)
60
71
  end
61
72
  end
@@ -2,22 +2,26 @@
2
2
 
3
3
  require 'rails/generators'
4
4
  require 'lighthouse-graphql'
5
+ require_relative '../base_classes'
5
6
 
6
7
  # `rails generate lighthouse:install` — sets a Rails app up for Lighthouse:
7
- # the initializer, a starter SDL file, the schema class, a controller and
8
- # its route, plus the graphql-config file IDEs read. Rerunning it skips the
9
- # route, SDL and .gitignore entries that are already in place.
8
+ # the initializer, a starter SDL file, the schema class, the base classes of
9
+ # resolvers, mutations and subscriptions, a controller and its route, plus the
10
+ # graphql-config file IDEs read. Rerunning it skips the route, SDL, base classes
11
+ # and .gitignore entries that are already in place.
10
12
  #
11
13
  # It coexists with graphql-ruby's `graphql:install`, which writes the same
12
14
  # controller path and route: an existing schema class or controller is kept
13
15
  # (pass --force to replace it) instead of overwritten, so an app already
14
16
  # serving a graphql-ruby schema keeps doing so until it is pointed at ours.
15
17
  class Lighthouse::Generators::InstallGenerator < Rails::Generators::Base
18
+ include Lighthouse::Generators::BaseClasses
19
+
16
20
  CONTROLLER = 'app/controllers/graphql_controller.rb'
17
21
 
18
22
  source_root File.expand_path('templates', __dir__)
19
23
 
20
- desc 'Install Lighthouse: initializer, starter schema, schema class, controller, route and IDE config.'
24
+ desc 'Install Lighthouse: initializer, starter schema, schema class, base classes, controller, route and IDE config.'
21
25
 
22
26
  class_option :schema, type: :string, default: 'AppSchema',
23
27
  desc: 'Name of the schema constant to generate'
@@ -48,6 +52,12 @@ class Lighthouse::Generators::InstallGenerator < Rails::Generators::Base
48
52
  template 'schema.rb.tt', path
49
53
  end
50
54
 
55
+ # Resolvers::BaseResolver, Mutations::BaseMutation and Subscriptions::BaseSubscription,
56
+ # in the namespaces Config.namespaces names first (see BaseClasses).
57
+ def create_base_classes
58
+ BASES.each_key { |kind| write_base_class(kind, Lighthouse::Config.namespaces[kind].first) }
59
+ end
60
+
51
61
  def create_controller
52
62
  return if options[:skip_controller]
53
63
  return if (@kept_controller = keep_existing?(CONTROLLER))
@@ -93,8 +103,10 @@ class Lighthouse::Generators::InstallGenerator < Rails::Generators::Base
93
103
 
94
104
  Lighthouse is installed. Next steps:
95
105
  1. Describe your API in #{sdl_folder}/*.graphql (the starter schema assumes a User model).
96
- 2. Generate the IDE helper files: bin/rails lighthouse:ide_helper
97
- 3. #{serve}
106
+ 2. Resolve a field in Ruby when a directive is not enough: bin/rails g lighthouse:query LatestPosts
107
+ (its class inherits from Resolvers::BaseResolver; mutations and subscriptions have a base too)
108
+ 3. Generate the IDE helper files: bin/rails lighthouse:ide_helper
109
+ 4. #{serve}
98
110
  MSG
99
111
  end
100
112
 
@@ -113,6 +125,11 @@ class Lighthouse::Generators::InstallGenerator < Rails::Generators::Base
113
125
  options[:schema].camelize
114
126
  end
115
127
 
128
+ # `Resolvers::BaseResolver`, for the schema class template.
129
+ def base_class_constant(kind)
130
+ "#{Lighthouse::Config.namespaces[kind].first}::#{base_class_name(kind)}"
131
+ end
132
+
116
133
  def sdl_folder
117
134
  options[:sdl_folder].delete_suffix('/')
118
135
  end
@@ -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 Queries::LatestPosts (a GraphQL::Schema::Resolver), Mutation.publishPost
16
- # to Mutations::PublishPost (a GraphQL::Schema::Mutation). @builder classes
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 GraphQL::Schema::Subscription),
19
- # and @event(dispatch:) classes come from :events. Generate them with
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: ['Queries'], mutations: ['Mutations'], subscriptions: ['Subscriptions'],
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