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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +99 -1
  3. data/README.md +22 -3
  4. data/docs/best-practices.md +16 -6
  5. data/docs/caching.md +1 -1
  6. data/docs/configuration.md +74 -5
  7. data/docs/custom-directives.md +11 -1
  8. data/docs/directives.md +333 -2
  9. data/docs/events.md +203 -0
  10. data/docs/federation.md +3 -2
  11. data/docs/getting-started.md +90 -23
  12. data/docs/motivation.md +3 -2
  13. data/docs/mutations.md +15 -0
  14. data/docs/resolvers.md +95 -19
  15. data/docs/security.md +210 -0
  16. data/docs/subscriptions.md +446 -0
  17. data/lib/generators/lighthouse/base_classes.rb +87 -0
  18. data/lib/generators/lighthouse/class_generator.rb +19 -8
  19. data/lib/generators/lighthouse/install/install_generator.rb +23 -6
  20. data/lib/generators/lighthouse/install/templates/graphql_controller.rb.tt +7 -45
  21. data/lib/generators/lighthouse/install/templates/initializer.rb.tt +34 -5
  22. data/lib/generators/lighthouse/install/templates/schema.rb.tt +14 -2
  23. data/lib/generators/lighthouse/mutation_generator.rb +5 -4
  24. data/lib/generators/lighthouse/query_generator.rb +5 -4
  25. data/lib/generators/lighthouse/subscription_generator.rb +100 -0
  26. data/lib/lighthouse/broadcast_job.rb +14 -0
  27. data/lib/lighthouse/broadcasting.rb +143 -0
  28. data/lib/lighthouse/cache_control.rb +5 -2
  29. data/lib/lighthouse/complexity.rb +174 -0
  30. data/lib/lighthouse/complexity_analyzer.rb +56 -0
  31. data/lib/lighthouse/config.rb +56 -14
  32. data/lib/lighthouse/controller.rb +131 -0
  33. data/lib/lighthouse/directive_resolvers/broadcast_resolver.rb +31 -0
  34. data/lib/lighthouse/directive_resolvers/convention_resolver.rb +14 -4
  35. data/lib/lighthouse/directive_resolvers/convert_empty_strings_resolver.rb +134 -0
  36. data/lib/lighthouse/directive_resolvers/event_resolver.rb +37 -0
  37. data/lib/lighthouse/directive_resolvers/model_mutation_resolver.rb +28 -13
  38. data/lib/lighthouse/directive_resolvers/soft_delete_resolvers.rb +66 -0
  39. data/lib/lighthouse/directive_resolvers/subscription_resolver.rb +76 -0
  40. data/lib/lighthouse/directives/arguments/soft_deletes.rb +112 -0
  41. data/lib/lighthouse/directives/registry.rb +15 -6
  42. data/lib/lighthouse/directives/soft_deletes_directive.rb +123 -0
  43. data/lib/lighthouse/events/execution_trace.rb +52 -0
  44. data/lib/lighthouse/events/lifecycle.rb +148 -0
  45. data/lib/lighthouse/events/schema_build.rb +44 -0
  46. data/lib/lighthouse/events.rb +76 -0
  47. data/lib/lighthouse/feature_directive.rb +137 -0
  48. data/lib/lighthouse/field_resolver_stack.rb +28 -7
  49. data/lib/lighthouse/graphql/version.rb +1 -1
  50. data/lib/lighthouse/namespaces.rb +1 -0
  51. data/lib/lighthouse/persisted_queries/batch.rb +34 -0
  52. data/lib/lighthouse/persisted_queries.rb +17 -4
  53. data/lib/lighthouse/rb_lighthouse.rb +23 -0
  54. data/lib/lighthouse/resolver_bases.rb +61 -0
  55. data/lib/lighthouse/schema_cache.rb +12 -5
  56. data/lib/lighthouse/schema_factory.rb +0 -1
  57. data/lib/lighthouse/schema_generator.rb +42 -9
  58. data/lib/lighthouse/sdl_loader.rb +0 -2
  59. data/lib/lighthouse/subscription.rb +120 -0
  60. data/lib/lighthouse/support/model_resolver.rb +1 -1
  61. data/lib/lighthouse/support/soft_deletes.rb +164 -0
  62. data/lib/lighthouse/tasks/lighthouse.rake +10 -0
  63. data/sig/lighthouse/controller.rbs +30 -0
  64. data/sig/lighthouse/events.rbs +122 -0
  65. data/sig/lighthouse/graphql.rbs +62 -0
  66. data/sig/lighthouse.rbs +52 -1
  67. metadata +29 -1
@@ -75,16 +75,44 @@ argument (the `@eq` makes `id` a filter). Lighthouse infers the model from the
75
75
  field's return type name (`User`), or you can be explicit with
76
76
  `@all(model: "User")`.
77
77
 
78
- ## 2. Provide your base types
78
+ ## 2. Provide your base classes
79
79
 
80
- Lighthouse builds on graphql-ruby, so you supply the base classes it should use
81
- for objects, interfaces, etc. — exactly the place to mix in Apollo Federation or
82
- any custom field/argument behavior.
80
+ Lighthouse builds on graphql-ruby, and follows its convention: each kind of class
81
+ inherits from a base class in its own namespace, where what they all share goes
82
+ once. `rails g lighthouse:install` writes the bases of the classes that resolve
83
+ root fields:
83
84
 
84
85
  ```ruby
85
- # app/graphql/base_object.rb
86
- class BaseObject < GraphQL::Schema::Object
87
- # include ApolloFederation::Object # if you use federation
86
+ # app/graphql/resolvers/base_resolver.rb — Query fields (Resolvers::LatestPosts)
87
+ module Resolvers
88
+ class BaseResolver < GraphQL::Schema::Resolver
89
+ end
90
+ end
91
+
92
+ # app/graphql/mutations/base_mutation.rb — Mutation fields (Mutations::PublishPost)
93
+ module Mutations
94
+ class BaseMutation < GraphQL::Schema::Mutation
95
+ end
96
+ end
97
+
98
+ # app/graphql/subscriptions/base_subscription.rb — Subscription fields
99
+ module Subscriptions
100
+ class BaseSubscription < GraphQL::Schema::Subscription
101
+ include Lighthouse::GraphQL::Subscription
102
+ end
103
+ end
104
+ ```
105
+
106
+ The SDL's types can have graphql-ruby base classes too, as `graphql:install`
107
+ writes them: the place to mix in Apollo Federation or custom field and argument
108
+ behavior.
109
+
110
+ ```ruby
111
+ # app/graphql/types/base_object.rb
112
+ module Types
113
+ class BaseObject < GraphQL::Schema::Object
114
+ # include ApolloFederation::Object # if you use federation
115
+ end
88
116
  end
89
117
  ```
90
118
 
@@ -98,13 +126,22 @@ AppSchema = Lighthouse::GraphQL::RbLightHouse.get_schema(
98
126
  sdl_folder: Rails.root.join('graphql').to_s,
99
127
  options: {
100
128
  base_types: {
101
- object: BaseObject,
129
+ resolver: Resolvers::BaseResolver,
130
+ mutation: Mutations::BaseMutation,
131
+ subscription: Subscriptions::BaseSubscription,
132
+ object: Types::BaseObject,
102
133
  # interface:, union:, enum:, input_object:, scalar: ...
103
134
  }
104
135
  }
105
136
  )
106
137
  ```
107
138
 
139
+ `object:` and the other SDL kinds go to graphql-ruby, which builds the SDL's types
140
+ from them. `resolver:`, `mutation:` and `subscription:` are Lighthouse's: every
141
+ class a `Query`, `Mutation` or `Subscription` field resolves to must inherit from
142
+ its kind's base, and a `Subscription` field with no class of its own resolves with
143
+ the subscription base. See [resolvers.md](resolvers.md#base-classes).
144
+
108
145
  `get_schema` returns a `GraphQL::Schema` subclass you can use directly, or
109
146
  subclass further (e.g. to add federation, tracing, a Dataloader):
110
147
 
@@ -119,22 +156,51 @@ end
119
156
 
120
157
  ## 4. Serve it
121
158
 
122
- A standard graphql-ruby controller:
159
+ `rails g lighthouse:install` writes the controller. It includes
160
+ `Lighthouse::GraphQL::Controller`, a concern that serves the schema:
123
161
 
124
162
  ```ruby
125
163
  class GraphqlController < ApplicationController
126
- def execute
127
- result = AppSchema.execute(
128
- params[:query],
129
- variables: params[:variables],
130
- context: { current_user: current_user },
131
- operation_name: params[:operationName]
132
- )
133
- render json: result
164
+ include Lighthouse::GraphQL::Controller
165
+
166
+ lighthouse_schema AppSchema # or 'AppSchema', or a block: lighthouse_schema { AppSchema }
167
+
168
+ private
169
+
170
+ # What @guard and the @can* directives read. Defaults to
171
+ # { current_user: current_user } when the controller has a current_user.
172
+ def graphql_context = { current_user: current_user }
173
+ end
174
+ ```
175
+
176
+ Route to it (`post '/graphql', to: 'graphql#execute'`, plus a `get` for
177
+ [persisted queries](performance.md#persisted-queries) if you use them). Its
178
+ `execute` action:
179
+
180
+ - dispatches the [`StartRequest` and `EndRequest` events](events.md);
181
+ - reads `variables` (a JSON string, as a GET carries it, or parameters);
182
+ - runs the operation through `PersistedQueries.execute`, so
183
+ [Automatic Persisted Queries](performance.md#persisted-queries) work once
184
+ `config.persisted_queries` is on, and a JSON array body runs as a batch;
185
+ - sets the `Cache-Control` header the selected fields'
186
+ [`@cacheControl`](caching.md) hints allow;
187
+ - renders the JSON; in development an error renders as JSON with its backtrace.
188
+
189
+ The hooks `graphql_context`, `graphql_schema` and `graphql_variables(param)` are
190
+ private methods you can override. A controller with an action of its own wraps it
191
+ in `lighthouse_request { ... }` to keep the request events:
192
+
193
+ ```ruby
194
+ def search
195
+ lighthouse_request do
196
+ render json: Lighthouse::GraphQL::PersistedQueries.execute(AppSchema, query: params[:q], context: graphql_context)
134
197
  end
135
198
  end
136
199
  ```
137
200
 
201
+ Not on the concern? Plain graphql-ruby works too:
202
+ `render json: AppSchema.execute(params[:query], variables: params[:variables], context: { current_user: current_user })`.
203
+
138
204
  ## 5. Configure (optional)
139
205
 
140
206
  Create an initializer to wire app-specific settings. This is also the single
@@ -218,18 +284,19 @@ type User {
218
284
  }
219
285
  ```
220
286
 
221
- ## Custom queries and mutations
287
+ ## Custom resolvers and mutations
222
288
 
223
289
  When a field needs Ruby, write a graphql-ruby resolver or mutation class named
224
- after it — `Query.latestPosts` resolves to `Queries::LatestPosts`
225
- (`app/graphql/queries/latest_posts.rb`), `Mutation.publishPost` to
226
- `Mutations::PublishPost`. `bin/rails generate lighthouse:query LatestPosts`
227
- scaffolds one. See [resolvers.md](resolvers.md).
290
+ after it — `Query.latestPosts` resolves to `Resolvers::LatestPosts`
291
+ (`app/graphql/resolvers/latest_posts.rb`, a `Resolvers::BaseResolver`),
292
+ `Mutation.publishPost` to `Mutations::PublishPost` (a `Mutations::BaseMutation`).
293
+ `bin/rails generate lighthouse:query LatestPosts` scaffolds one. See
294
+ [resolvers.md](resolvers.md).
228
295
 
229
296
  ## Next steps
230
297
 
231
298
  - [Directives reference](directives.md) — the full vocabulary.
232
- - [Queries, mutations & builders](resolvers.md), [Mutations](mutations.md),
299
+ - [Resolvers, mutations & builders](resolvers.md), [Mutations](mutations.md),
233
300
  [Validation](validation.md), [Caching](caching.md).
234
301
  - [Relationships](relationships.md) — wiring associations.
235
302
  - [Filtering & ordering](filtering-and-ordering.md) — query constraints.
data/docs/motivation.md CHANGED
@@ -151,8 +151,9 @@ Schema-first doesn't mean schema-only. When a field does something no directive
151
151
  describes, write Ruby, using graphql-ruby's own classes:
152
152
 
153
153
  - A `Query` or `Mutation` field without a resolving directive resolves by convention to
154
- `Queries::FieldName` (a `GraphQL::Schema::Resolver`) or `Mutations::FieldName` (a
155
- `GraphQL::Schema::Mutation`). See [Queries, Mutations and Builders](resolvers.md).
154
+ `Resolvers::FieldName` (a `Resolvers::BaseResolver`) or `Mutations::FieldName` (a
155
+ `Mutations::BaseMutation`), under the base classes graphql-ruby's convention gives
156
+ each kind. See [Resolvers, Mutations and Builders](resolvers.md).
156
157
  - `@builder` classes adjust a query; `@validator` contracts check arguments.
157
158
  - Your own directives plug in through the registry, with the same contracts as the
158
159
  built-in ones. See [Custom Directives](custom-directives.md).
data/docs/mutations.md CHANGED
@@ -96,6 +96,21 @@ Without a filter argument, it refuses to run, since it would delete every row:
96
96
  abort the destroy is reported next to the data:
97
97
  `Could not modify model Post with ID 1.`
98
98
 
99
+ ### `@forceDelete` and `@restore`
100
+
101
+ `@forceDelete` permanently removes soft deleted models and `@restore` un-deletes
102
+ them. They work very much like `@delete` (required filter arguments, the models
103
+ returned, `model:` and `scopes:`), but also find the models the soft-delete
104
+ strategy hides. See [Soft deletes & global
105
+ scopes](directives.md#soft-deletes-global-scopes).
106
+
107
+ ```graphql
108
+ type Mutation {
109
+ forceDeletePost(id: ID! @whereKey): Post @forceDelete
110
+ restorePost(id: ID! @whereKey): Post @restore
111
+ }
112
+ ```
113
+
99
114
  ## Validation errors
100
115
 
101
116
  When a model does not save, the mutation fails with PHP Lighthouse's error
data/docs/resolvers.md CHANGED
@@ -1,22 +1,92 @@
1
- # Queries, Mutations and Builders
1
+ # Resolvers, Mutations and Builders
2
2
 
3
3
  Most fields need no Ruby: a directive (`@all`, `@find`, `@paginate`, `@create`,
4
4
  …) or the default attribute reader resolves them. For the rest, Lighthouse looks
5
- up a class by name. There are three kinds of class, and they are not
5
+ up a class by name. There are several kinds of class, and they are not
6
6
  interchangeable. Each kind has its own contract and its own folder:
7
7
 
8
8
  | Kind | Folder / namespace | Contract | Used by |
9
9
  | --- | --- | --- | --- |
10
- | Query | `app/graphql/queries` · `Queries::` | `GraphQL::Schema::Resolver` | `Query` fields |
11
- | Mutation | `app/graphql/mutations` · `Mutations::` | `GraphQL::Schema::Mutation` | `Mutation` fields |
10
+ | Resolver | `app/graphql/resolvers` · `Resolvers::` | `Resolvers::BaseResolver` (a `GraphQL::Schema::Resolver`) | `Query` fields |
11
+ | Mutation | `app/graphql/mutations` · `Mutations::` | `Mutations::BaseMutation` (a `GraphQL::Schema::Mutation`) | `Mutation` fields |
12
+ | Subscription | `app/graphql/subscriptions` · `Subscriptions::` | `Subscriptions::BaseSubscription` (a `GraphQL::Schema::Subscription`) | `Subscription` fields ([subscriptions.md](subscriptions.md)) |
12
13
  | Builder | `app/graphql/builders` · `Builders::` | `call(relation, args, context)` → relation | `@builder` |
13
14
 
14
15
  Validators (`app/graphql/validators`) are the fourth kind. They are covered in
15
16
  [validation.md](validation.md).
16
17
 
17
- ## Queries and mutations: graphql-ruby's contract
18
+ ## Base classes
18
19
 
19
- A query or mutation class is a plain graphql-ruby
20
+ As in any graphql-ruby app, each of the first three kinds has a base class in its
21
+ namespace, and every class of the kind inherits from it:
22
+
23
+ ```ruby
24
+ # app/graphql/resolvers/base_resolver.rb
25
+ module Resolvers
26
+ class BaseResolver < GraphQL::Schema::Resolver
27
+ end
28
+ end
29
+
30
+ # app/graphql/mutations/base_mutation.rb
31
+ module Mutations
32
+ class BaseMutation < GraphQL::Schema::Mutation
33
+ end
34
+ end
35
+
36
+ # app/graphql/subscriptions/base_subscription.rb
37
+ module Subscriptions
38
+ class BaseSubscription < GraphQL::Schema::Subscription
39
+ include Lighthouse::GraphQL::Subscription
40
+ end
41
+ end
42
+ ```
43
+
44
+ What every class of a kind shares goes in its base, once: a concern (as
45
+ `BaseSubscription` includes Lighthouse's, see [subscriptions.md](subscriptions.md)),
46
+ a helper, graphql-ruby settings such as `argument_class Types::BaseArgument`.
47
+ `rails g lighthouse:install` writes the three, and the class generators write the
48
+ one they need when it is missing. graphql-ruby's own `graphql:install` writes
49
+ `Resolvers::BaseResolver` and `Mutations::BaseMutation` too, and Lighthouse keeps
50
+ whatever is already there: an existing base is never replaced.
51
+
52
+ ### Handing the bases to the schema
53
+
54
+ Name the bases in `base_types`, beside graphql-ruby's own base types, and the
55
+ schema holds every class to them:
56
+
57
+ ```ruby
58
+ # app/graphql/app_schema.rb
59
+ AppSchema = Lighthouse::GraphQL::RbLightHouse.get_schema(
60
+ options: {
61
+ base_types: {
62
+ resolver: Resolvers::BaseResolver,
63
+ mutation: Mutations::BaseMutation,
64
+ subscription: Subscriptions::BaseSubscription
65
+ # object: Types::BaseObject, ... (graphql-ruby's, for the SDL's types)
66
+ }
67
+ }
68
+ )
69
+ ```
70
+
71
+ - A class a `Query`, `Mutation` or `Subscription` field resolves to, by convention
72
+ or through `@subscription(class:)`, that does not inherit from its kind's base
73
+ raises `Lighthouse::DefinitionError` when the field is first used (`…so it must
74
+ inherit from Resolvers::BaseResolver…`). No class misses what the base shares.
75
+ - A `Subscription` field with no class of its own resolves with the subscription
76
+ base, so its concern and settings apply there too.
77
+ - A base must be a subclass of the graphql-ruby class of its kind
78
+ (`GraphQL::Schema::Resolver`, `GraphQL::Schema::Mutation`, which
79
+ `RelayClassicMutation` is, `GraphQL::Schema::Subscription`), or the schema does not
80
+ build. graphql-ruby never sees these three keys.
81
+
82
+ `rails g lighthouse:install` writes the schema class with them. Without them,
83
+ Lighthouse requires only the graphql-ruby class (`GraphQL::Schema::Resolver`, …),
84
+ and a `Subscription` field with no class resolves with a built-in subscription that
85
+ includes the concern.
86
+
87
+ ## Resolvers and mutations: graphql-ruby's contract
88
+
89
+ A resolver or mutation class is a plain graphql-ruby
20
90
  [resolver](https://graphql-ruby.org/fields/resolvers.html) or
21
91
  [mutation](https://graphql-ruby.org/mutations/mutation_classes.html), so everything
22
92
  you know from graphql-ruby applies:
@@ -39,9 +109,9 @@ type Mutation {
39
109
  ```
40
110
 
41
111
  ```ruby
42
- # app/graphql/queries/latest_posts.rb
43
- module Queries
44
- class LatestPosts < GraphQL::Schema::Resolver
112
+ # app/graphql/resolvers/latest_posts.rb
113
+ module Resolvers
114
+ class LatestPosts < BaseResolver
45
115
  def resolve(limit: 10)
46
116
  Post.order(created_at: :desc).limit(limit)
47
117
  end
@@ -50,7 +120,7 @@ end
50
120
 
51
121
  # app/graphql/mutations/publish_post.rb
52
122
  module Mutations
53
- class PublishPost < GraphQL::Schema::Mutation
123
+ class PublishPost < BaseMutation
54
124
  def authorized?(id:)
55
125
  context[:current_user]&.admin? || raise(GraphQL::ExecutionError, 'Admins only.')
56
126
  end
@@ -67,7 +137,7 @@ end
67
137
  A field on `Query` or `Mutation` with **no resolving directive** resolves to the
68
138
  class named after it, as in PHP Lighthouse:
69
139
 
70
- - `Query.latestPosts` resolves to `Queries::LatestPosts`;
140
+ - `Query.latestPosts` resolves to `Resolvers::LatestPosts`;
71
141
  - `Mutation.publishPost` resolves to `Mutations::PublishPost`.
72
142
 
73
143
  The class is looked up on each call, so code reloading works. Classes found by
@@ -77,8 +147,8 @@ field with no class is reported through [`on_error`](configuration.md#on_error):
77
147
  it raises in test and logs elsewhere. Lighthouse never falls back to a top-level
78
148
  constant, so a `post` field is never mistaken for the `Post` model.
79
149
 
80
- graphql-ruby's `RelayClassicMutation` (what `rails g graphql:install` generates
81
- `Mutations::BaseMutation` from) works too, as long as the SDL field takes the
150
+ graphql-ruby's `RelayClassicMutation` (what `rails g graphql:install` makes
151
+ `Mutations::BaseMutation` inherit from) works too, as long as the SDL field takes the
82
152
  `input:` argument it expects.
83
153
 
84
154
  Wrapping directives (`@guard`, the `@can*` family, `@validator`, `@cache`,
@@ -94,12 +164,12 @@ type Mutation {
94
164
  ### Naming a class explicitly
95
165
 
96
166
  `@field(resolver:)` points a field at a class by name. On `Query` and `Mutation`
97
- the name is looked up in the queries and mutations namespaces; on any other type,
167
+ the name is looked up in the resolvers and mutations namespaces; on any other type,
98
168
  in [`resolver_namespaces`](configuration.md#resolver_namespaces).
99
169
 
100
170
  ```graphql
101
171
  type Query {
102
- feed: [Post!]! @field(resolver: "Feeds::Home") # Queries::Feeds::Home
172
+ feed: [Post!]! @field(resolver: "Feeds::Home") # Resolvers::Feeds::Home
103
173
  }
104
174
  ```
105
175
 
@@ -165,15 +235,18 @@ end
165
235
  ## Generators
166
236
 
167
237
  ```shell
168
- bin/rails generate lighthouse:query LatestPosts # app/graphql/queries/latest_posts.rb
238
+ bin/rails generate lighthouse:query LatestPosts # app/graphql/resolvers/latest_posts.rb
169
239
  bin/rails generate lighthouse:mutation PublishPost # app/graphql/mutations/publish_post.rb
240
+ bin/rails generate lighthouse:subscription PostUpdated # app/graphql/subscriptions/post_updated.rb
170
241
  bin/rails generate lighthouse:builder Published # app/graphql/builders/published.rb
171
242
  bin/rails generate lighthouse:validator CreatePostInput # app/graphql/validators/create_post_input_validator.rb
172
243
  ```
173
244
 
174
245
  Each one writes the class with its contract, into the first namespace configured
175
- for its kind (or `--namespace=…`). Namespaced names nest modules:
176
- `lighthouse:mutation Blog::Publish` writes `Mutations::Blog::Publish`. These
246
+ for its kind (or `--namespace=…`). A resolver, mutation or subscription inherits
247
+ from the namespace's base class, which is written first when it is missing.
248
+ Namespaced names nest modules: `lighthouse:mutation Blog::Publish` writes
249
+ `Mutations::Blog::Publish < BaseMutation`. These
177
250
  generators do not collide with graphql-ruby's `graphql:*` generators. A
178
251
  `lighthouse:mutation` and a `graphql:mutation` of the same name target the same
179
252
  file, and Rails asks before overwriting it.
@@ -182,8 +255,11 @@ file, and Rails asks before overwriting it.
182
255
 
183
256
  ```ruby
184
257
  Lighthouse.configure do |config|
185
- config.namespaces = { queries: ['Api::Queries'], mutations: ['Api::Mutations'] }
258
+ config.namespaces = { queries: ['Api::Resolvers'], mutations: ['Api::Mutations'] }
186
259
  end
187
260
  ```
188
261
 
189
262
  Kinds you leave out keep their defaults. Each kind takes a list, tried in order.
263
+ The `queries` kind is the classes of `Query` fields. Before 0.4.0 it defaulted to
264
+ `Queries`; an app keeping its classes in `app/graphql/queries` sets
265
+ `config.namespaces = { queries: ['Queries'] }`.
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.