lighthouse-graphql 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +99 -1
- data/README.md +22 -3
- data/docs/best-practices.md +16 -6
- data/docs/caching.md +1 -1
- data/docs/configuration.md +74 -5
- data/docs/custom-directives.md +11 -1
- data/docs/directives.md +333 -2
- data/docs/events.md +203 -0
- data/docs/federation.md +3 -2
- data/docs/getting-started.md +90 -23
- data/docs/motivation.md +3 -2
- data/docs/mutations.md +15 -0
- data/docs/resolvers.md +95 -19
- data/docs/security.md +210 -0
- data/docs/subscriptions.md +446 -0
- data/lib/generators/lighthouse/base_classes.rb +87 -0
- data/lib/generators/lighthouse/class_generator.rb +19 -8
- data/lib/generators/lighthouse/install/install_generator.rb +23 -6
- data/lib/generators/lighthouse/install/templates/graphql_controller.rb.tt +7 -45
- data/lib/generators/lighthouse/install/templates/initializer.rb.tt +34 -5
- data/lib/generators/lighthouse/install/templates/schema.rb.tt +14 -2
- data/lib/generators/lighthouse/mutation_generator.rb +5 -4
- data/lib/generators/lighthouse/query_generator.rb +5 -4
- data/lib/generators/lighthouse/subscription_generator.rb +100 -0
- data/lib/lighthouse/broadcast_job.rb +14 -0
- data/lib/lighthouse/broadcasting.rb +143 -0
- data/lib/lighthouse/cache_control.rb +5 -2
- data/lib/lighthouse/complexity.rb +174 -0
- data/lib/lighthouse/complexity_analyzer.rb +56 -0
- data/lib/lighthouse/config.rb +56 -14
- data/lib/lighthouse/controller.rb +131 -0
- data/lib/lighthouse/directive_resolvers/broadcast_resolver.rb +31 -0
- data/lib/lighthouse/directive_resolvers/convention_resolver.rb +14 -4
- data/lib/lighthouse/directive_resolvers/convert_empty_strings_resolver.rb +134 -0
- data/lib/lighthouse/directive_resolvers/event_resolver.rb +37 -0
- data/lib/lighthouse/directive_resolvers/model_mutation_resolver.rb +28 -13
- data/lib/lighthouse/directive_resolvers/soft_delete_resolvers.rb +66 -0
- data/lib/lighthouse/directive_resolvers/subscription_resolver.rb +76 -0
- data/lib/lighthouse/directives/arguments/soft_deletes.rb +112 -0
- data/lib/lighthouse/directives/registry.rb +15 -6
- data/lib/lighthouse/directives/soft_deletes_directive.rb +123 -0
- data/lib/lighthouse/events/execution_trace.rb +52 -0
- data/lib/lighthouse/events/lifecycle.rb +148 -0
- data/lib/lighthouse/events/schema_build.rb +44 -0
- data/lib/lighthouse/events.rb +76 -0
- data/lib/lighthouse/feature_directive.rb +137 -0
- data/lib/lighthouse/field_resolver_stack.rb +28 -7
- data/lib/lighthouse/graphql/version.rb +1 -1
- data/lib/lighthouse/namespaces.rb +1 -0
- data/lib/lighthouse/persisted_queries/batch.rb +34 -0
- data/lib/lighthouse/persisted_queries.rb +17 -4
- data/lib/lighthouse/rb_lighthouse.rb +23 -0
- data/lib/lighthouse/resolver_bases.rb +61 -0
- data/lib/lighthouse/schema_cache.rb +12 -5
- data/lib/lighthouse/schema_factory.rb +0 -1
- data/lib/lighthouse/schema_generator.rb +42 -9
- data/lib/lighthouse/sdl_loader.rb +0 -2
- data/lib/lighthouse/subscription.rb +120 -0
- data/lib/lighthouse/support/model_resolver.rb +1 -1
- data/lib/lighthouse/support/soft_deletes.rb +164 -0
- data/lib/lighthouse/tasks/lighthouse.rake +10 -0
- data/sig/lighthouse/controller.rbs +30 -0
- data/sig/lighthouse/events.rbs +122 -0
- data/sig/lighthouse/graphql.rbs +62 -0
- data/sig/lighthouse.rbs +52 -1
- metadata +29 -1
data/docs/getting-started.md
CHANGED
|
@@ -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
|
|
78
|
+
## 2. Provide your base classes
|
|
79
79
|
|
|
80
|
-
Lighthouse builds on graphql-ruby,
|
|
81
|
-
|
|
82
|
-
|
|
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/
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
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 `
|
|
225
|
-
(`app/graphql/
|
|
226
|
-
`Mutations::PublishPost
|
|
227
|
-
scaffolds one. See
|
|
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
|
-
- [
|
|
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
|
-
`
|
|
155
|
-
`
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
|
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
|
-
##
|
|
18
|
+
## Base classes
|
|
18
19
|
|
|
19
|
-
|
|
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/
|
|
43
|
-
module
|
|
44
|
-
class LatestPosts <
|
|
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 <
|
|
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 `
|
|
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`
|
|
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
|
|
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") #
|
|
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/
|
|
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=…`).
|
|
176
|
-
|
|
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::
|
|
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.
|