lighthouse-graphql 0.3.0 → 0.5.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 +182 -1
- data/README.md +18 -7
- data/docs/best-practices.md +16 -6
- data/docs/configuration.md +33 -8
- data/docs/directives.md +318 -10
- data/docs/events.md +3 -1
- data/docs/federation.md +3 -2
- data/docs/generators.md +82 -0
- data/docs/getting-started.md +66 -32
- data/docs/motivation.md +3 -2
- data/docs/relationships.md +42 -0
- data/docs/relay.md +137 -0
- data/docs/resolvers.md +338 -25
- data/docs/security.md +227 -0
- data/docs/subscriptions.md +60 -37
- data/docs/types.md +219 -0
- data/docs/uploads.md +80 -0
- data/lib/generators/lighthouse/base_classes.rb +70 -0
- data/lib/generators/lighthouse/class_generator.rb +19 -8
- data/lib/generators/lighthouse/install/install_generator.rb +96 -20
- data/lib/generators/lighthouse/install/templates/initializer.rb.tt +19 -10
- data/lib/generators/lighthouse/install/templates/schema.rb.tt +21 -2
- data/lib/generators/lighthouse/mutation_generator.rb +5 -4
- data/lib/generators/lighthouse/query_generator.rb +5 -4
- data/lib/generators/lighthouse/subscription_generator.rb +21 -16
- data/lib/lighthouse/async_mutation_job.rb +17 -0
- data/lib/lighthouse/complexity.rb +175 -0
- data/lib/lighthouse/complexity_analyzer.rb +56 -0
- data/lib/lighthouse/config.rb +16 -4
- data/lib/lighthouse/controller.rb +27 -9
- data/lib/lighthouse/dataloader_sources/preload_loader.rb +16 -0
- data/lib/lighthouse/dataloader_sources/relation_counts_loader.rb +46 -0
- data/lib/lighthouse/default_resolve_dispatcher.rb +26 -0
- data/lib/lighthouse/directive_resolvers/all_resolver.rb +1 -1
- data/lib/lighthouse/directive_resolvers/argument_binding.rb +61 -0
- data/lib/lighthouse/directive_resolvers/argument_transforms_resolver.rb +220 -0
- data/lib/lighthouse/directive_resolvers/association_resolvers.rb +2 -2
- data/lib/lighthouse/directive_resolvers/async_resolver.rb +90 -0
- data/lib/lighthouse/directive_resolvers/base.rb +63 -14
- data/lib/lighthouse/directive_resolvers/builder_applier.rb +89 -19
- data/lib/lighthouse/directive_resolvers/can_resolvers.rb +1 -1
- data/lib/lighthouse/directive_resolvers/convention_resolver.rb +16 -5
- data/lib/lighthouse/directive_resolvers/convert_empty_strings_resolver.rb +1 -1
- data/lib/lighthouse/directive_resolvers/count_resolver.rb +1 -1
- data/lib/lighthouse/directive_resolvers/field_resolver.rb +35 -7
- data/lib/lighthouse/directive_resolvers/first_resolver.rb +1 -1
- data/lib/lighthouse/directive_resolvers/global_id_resolver.rb +57 -0
- data/lib/lighthouse/directive_resolvers/lazy_load_resolver.rb +61 -0
- data/lib/lighthouse/directive_resolvers/limit_resolver.rb +59 -0
- data/lib/lighthouse/directive_resolvers/method_resolver.rb +38 -3
- data/lib/lighthouse/directive_resolvers/model_mutation_resolver.rb +1 -1
- data/lib/lighthouse/directive_resolvers/namespaced_resolver.rb +31 -0
- data/lib/lighthouse/directive_resolvers/paginate_resolver.rb +1 -1
- data/lib/lighthouse/directive_resolvers/resolution.rb +19 -4
- data/lib/lighthouse/directive_resolvers/subscription_resolver.rb +14 -5
- data/lib/lighthouse/directive_resolvers/throttle_resolver.rb +77 -0
- data/lib/lighthouse/directive_resolvers/type_method_resolver.rb +81 -0
- data/lib/lighthouse/directive_resolvers/with_count_resolver.rb +43 -0
- data/lib/lighthouse/directive_resolvers/with_resolver.rb +42 -0
- data/lib/lighthouse/directives/arguments/more_filters.rb +215 -0
- data/lib/lighthouse/directives/definitions.rb +52 -0
- data/lib/lighthouse/directives/model_directive.rb +131 -0
- data/lib/lighthouse/directives/node_directive.rb +134 -0
- data/lib/lighthouse/directives/pagination_manipulator.rb +1 -1
- data/lib/lighthouse/directives/registry.rb +21 -20
- data/lib/lighthouse/directives/visibility_directive.rb +71 -0
- data/lib/lighthouse/enum_directive.rb +41 -0
- data/lib/lighthouse/events/execution_trace.rb +2 -1
- data/lib/lighthouse/events/lifecycle.rb +3 -1
- data/lib/lighthouse/field_resolver_stack.rb +59 -11
- data/lib/lighthouse/generated_resolvers.rb +13 -8
- data/lib/lighthouse/global_id.rb +31 -0
- data/lib/lighthouse/graphql/version.rb +1 -1
- data/lib/lighthouse/multipart_request.rb +43 -0
- data/lib/lighthouse/node_registry.rb +68 -0
- data/lib/lighthouse/pagination.rb +3 -0
- data/lib/lighthouse/rb_lighthouse.rb +36 -0
- data/lib/lighthouse/relation_counts.rb +29 -0
- data/lib/lighthouse/resolve_info.rb +80 -0
- data/lib/lighthouse/resolver_bases.rb +68 -0
- data/lib/lighthouse/resolver_dispatch.rb +36 -11
- data/lib/lighthouse/scalar_coercion.rb +72 -0
- data/lib/lighthouse/schema_cache.rb +2 -1
- data/lib/lighthouse/schema_factory.rb +0 -1
- data/lib/lighthouse/schema_generator.rb +21 -12
- data/lib/lighthouse/schema_implementation.rb +4 -8
- data/lib/lighthouse/sdl_loader.rb +0 -2
- data/lib/lighthouse/support/environment.rb +13 -0
- data/lib/lighthouse/support/model_resolver.rb +8 -6
- data/lib/lighthouse/support/preload.rb +49 -0
- data/lib/lighthouse/type.rb +103 -0
- data/lib/lighthouse/type_resolution.rb +150 -0
- data/lib/lighthouse/upload.rb +26 -0
- data/lib/lighthouse/upload_store.rb +32 -0
- data/sig/lighthouse/directives.rbs +3 -2
- data/sig/lighthouse/graphql.rbs +29 -0
- data/sig/lighthouse.rbs +18 -0
- metadata +43 -1
data/docs/resolvers.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
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
|
|
@@ -7,17 +7,101 @@ 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 |
|
|
12
|
-
| Subscription | `app/graphql/subscriptions` · `Subscriptions::` | `GraphQL::Schema::Subscription` | `Subscription` fields ([subscriptions.md](subscriptions.md)) |
|
|
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)) |
|
|
13
|
+
| Type | `app/graphql/types` · `Types::` | `Types::BaseObject` (a `GraphQL::Schema::Object`) including `Lighthouse::GraphQL::Type`, named `<Type>Type` | Fields of other types, through its methods ([type classes](#type-classes)); unions, interfaces and scalars too ([types.md](types.md)) |
|
|
13
14
|
| Builder | `app/graphql/builders` · `Builders::` | `call(relation, args, context)` → relation | `@builder` |
|
|
14
15
|
|
|
15
|
-
Validators (`app/graphql/validators`) are
|
|
16
|
+
Validators (`app/graphql/validators`) are one more kind. They are covered in
|
|
16
17
|
[validation.md](validation.md).
|
|
17
18
|
|
|
18
|
-
##
|
|
19
|
+
## Base classes
|
|
19
20
|
|
|
20
|
-
|
|
21
|
+
As in any graphql-ruby app, each kind of class has a base class in its namespace,
|
|
22
|
+
and every class of the kind inherits from it. graphql-ruby's `graphql:install`,
|
|
23
|
+
which `rails g lighthouse:install` runs, writes `Resolvers::BaseResolver`,
|
|
24
|
+
`Mutations::BaseMutation` and the `Types::Base*` classes; Lighthouse adds
|
|
25
|
+
`Subscriptions::BaseSubscription`, which graphql-ruby has none of, and makes the
|
|
26
|
+
mutation base a plain `GraphQL::Schema::Mutation`, as the SDL declares a mutation's
|
|
27
|
+
arguments ([generators.md](generators.md)):
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
# app/graphql/resolvers/base_resolver.rb (graphql-ruby's)
|
|
31
|
+
module Resolvers
|
|
32
|
+
class BaseResolver < GraphQL::Schema::Resolver
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# app/graphql/mutations/base_mutation.rb (graphql-ruby's, made a Mutation)
|
|
37
|
+
module Mutations
|
|
38
|
+
class BaseMutation < GraphQL::Schema::Mutation
|
|
39
|
+
argument_class Types::BaseArgument
|
|
40
|
+
field_class Types::BaseField
|
|
41
|
+
object_class Types::BaseObject
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# app/graphql/subscriptions/base_subscription.rb (Lighthouse's)
|
|
46
|
+
module Subscriptions
|
|
47
|
+
class BaseSubscription < GraphQL::Schema::Subscription
|
|
48
|
+
include Lighthouse::GraphQL::Subscription
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
What every class of a kind shares goes in its base, once: a concern (as
|
|
54
|
+
`BaseSubscription` includes Lighthouse's, see [subscriptions.md](subscriptions.md)),
|
|
55
|
+
a helper, graphql-ruby settings such as `argument_class Types::BaseArgument`. An
|
|
56
|
+
existing base is never replaced.
|
|
57
|
+
|
|
58
|
+
### Handing the bases to the schema
|
|
59
|
+
|
|
60
|
+
Name the bases in `base_types`, beside graphql-ruby's own base types, and the
|
|
61
|
+
schema holds every class to them:
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
# app/graphql/app_schema.rb
|
|
65
|
+
AppSchema = Lighthouse::GraphQL::RbLightHouse.get_schema(
|
|
66
|
+
options: {
|
|
67
|
+
base_types: {
|
|
68
|
+
resolver: Resolvers::BaseResolver,
|
|
69
|
+
mutation: Mutations::BaseMutation,
|
|
70
|
+
subscription: Subscriptions::BaseSubscription,
|
|
71
|
+
object: Types::BaseObject,
|
|
72
|
+
interface: Types::BaseInterface,
|
|
73
|
+
union: Types::BaseUnion,
|
|
74
|
+
scalar: Types::BaseScalar,
|
|
75
|
+
enum: Types::BaseEnum,
|
|
76
|
+
input_object: Types::BaseInputObject
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
- A class a `Query`, `Mutation` or `Subscription` field resolves to, by convention
|
|
83
|
+
or through `@subscription(class:)`, that does not inherit from its kind's base
|
|
84
|
+
raises `Lighthouse::DefinitionError` when the field is first used (`…so it must
|
|
85
|
+
inherit from Resolvers::BaseResolver…`). No class misses what the base shares.
|
|
86
|
+
- A `Subscription` field with no class of its own resolves with the subscription
|
|
87
|
+
base, so its concern and settings apply there too.
|
|
88
|
+
- A base must be a subclass of the graphql-ruby class of its kind
|
|
89
|
+
(`GraphQL::Schema::Resolver`, `GraphQL::Schema::Mutation`, which
|
|
90
|
+
`RelayClassicMutation` is, `GraphQL::Schema::Subscription`), or the schema does not
|
|
91
|
+
build. graphql-ruby never sees these three keys.
|
|
92
|
+
- The others are graphql-ruby's: it builds the SDL's types of each kind from them.
|
|
93
|
+
A [type class](#type-classes) (one including `Lighthouse::GraphQL::Type`) that
|
|
94
|
+
does not inherit from its kind's raises `Lighthouse::DefinitionError` when the
|
|
95
|
+
schema is built.
|
|
96
|
+
|
|
97
|
+
`rails g lighthouse:install` writes the schema class with them. Without them,
|
|
98
|
+
Lighthouse requires only the graphql-ruby class (`GraphQL::Schema::Resolver`, …),
|
|
99
|
+
and a `Subscription` field with no class resolves with a built-in subscription that
|
|
100
|
+
includes the concern.
|
|
101
|
+
|
|
102
|
+
## Resolvers and mutations: graphql-ruby's contract
|
|
103
|
+
|
|
104
|
+
A resolver or mutation class is a plain graphql-ruby
|
|
21
105
|
[resolver](https://graphql-ruby.org/fields/resolvers.html) or
|
|
22
106
|
[mutation](https://graphql-ruby.org/mutations/mutation_classes.html), so everything
|
|
23
107
|
you know from graphql-ruby applies:
|
|
@@ -40,9 +124,9 @@ type Mutation {
|
|
|
40
124
|
```
|
|
41
125
|
|
|
42
126
|
```ruby
|
|
43
|
-
# app/graphql/
|
|
44
|
-
module
|
|
45
|
-
class LatestPosts <
|
|
127
|
+
# app/graphql/resolvers/latest_posts.rb
|
|
128
|
+
module Resolvers
|
|
129
|
+
class LatestPosts < BaseResolver
|
|
46
130
|
def resolve(limit: 10)
|
|
47
131
|
Post.order(created_at: :desc).limit(limit)
|
|
48
132
|
end
|
|
@@ -51,7 +135,7 @@ end
|
|
|
51
135
|
|
|
52
136
|
# app/graphql/mutations/publish_post.rb
|
|
53
137
|
module Mutations
|
|
54
|
-
class PublishPost <
|
|
138
|
+
class PublishPost < BaseMutation
|
|
55
139
|
def authorized?(id:)
|
|
56
140
|
context[:current_user]&.admin? || raise(GraphQL::ExecutionError, 'Admins only.')
|
|
57
141
|
end
|
|
@@ -68,7 +152,7 @@ end
|
|
|
68
152
|
A field on `Query` or `Mutation` with **no resolving directive** resolves to the
|
|
69
153
|
class named after it, as in PHP Lighthouse:
|
|
70
154
|
|
|
71
|
-
- `Query.latestPosts` resolves to `
|
|
155
|
+
- `Query.latestPosts` resolves to `Resolvers::LatestPosts`;
|
|
72
156
|
- `Mutation.publishPost` resolves to `Mutations::PublishPost`.
|
|
73
157
|
|
|
74
158
|
The class is looked up on each call, so code reloading works. Classes found by
|
|
@@ -78,8 +162,8 @@ field with no class is reported through [`on_error`](configuration.md#on_error):
|
|
|
78
162
|
it raises in test and logs elsewhere. Lighthouse never falls back to a top-level
|
|
79
163
|
constant, so a `post` field is never mistaken for the `Post` model.
|
|
80
164
|
|
|
81
|
-
graphql-ruby's `RelayClassicMutation` (what `rails g graphql:install`
|
|
82
|
-
`Mutations::BaseMutation` from) works too, as long as the SDL field takes the
|
|
165
|
+
graphql-ruby's `RelayClassicMutation` (what `rails g graphql:install` makes
|
|
166
|
+
`Mutations::BaseMutation` inherit from) works too, as long as the SDL field takes the
|
|
83
167
|
`input:` argument it expects.
|
|
84
168
|
|
|
85
169
|
Wrapping directives (`@guard`, the `@can*` family, `@validator`, `@cache`,
|
|
@@ -95,17 +179,243 @@ type Mutation {
|
|
|
95
179
|
### Naming a class explicitly
|
|
96
180
|
|
|
97
181
|
`@field(resolver:)` points a field at a class by name. On `Query` and `Mutation`
|
|
98
|
-
the name is looked up in the
|
|
182
|
+
the name is looked up in the resolvers and mutations namespaces; on any other type,
|
|
99
183
|
in [`resolver_namespaces`](configuration.md#resolver_namespaces).
|
|
100
184
|
|
|
101
185
|
```graphql
|
|
102
186
|
type Query {
|
|
103
|
-
feed: [Post!]! @field(resolver: "Feeds::Home") #
|
|
187
|
+
feed: [Post!]! @field(resolver: "Feeds::Home") # Resolvers::Feeds::Home
|
|
104
188
|
}
|
|
105
189
|
```
|
|
106
190
|
|
|
107
191
|
Besides graphql-ruby resolver classes, `@field` also accepts a plain callable:
|
|
108
|
-
anything with `call(object, args, context)
|
|
192
|
+
anything with `call(object, args, context)`, and `call(object, args, context,
|
|
193
|
+
resolve_info)` when it takes a fourth argument.
|
|
194
|
+
|
|
195
|
+
A method after `@` (PHP Lighthouse's `Class@method`) replaces `call`, or `resolve` on
|
|
196
|
+
a resolver class (whose `authorized?` still runs). One class can then hold several
|
|
197
|
+
fields' resolvers:
|
|
198
|
+
|
|
199
|
+
```graphql
|
|
200
|
+
type Query {
|
|
201
|
+
counts: Counts! @field(resolver: "Statistics@counts")
|
|
202
|
+
latest: [Post!]! @field(resolver: "Statistics@latest")
|
|
203
|
+
}
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
class Resolvers::Statistics
|
|
208
|
+
def self.counts(_root, _args, _context) = { posts: Post.count, users: User.count }
|
|
209
|
+
def self.latest(_root, _args, _context) = Post.order(created_at: :desc).limit(5)
|
|
210
|
+
end
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### ResolveInfo
|
|
214
|
+
|
|
215
|
+
What PHP Lighthouse hands a resolver as `$resolveInfo`. A `Resolvers::` or
|
|
216
|
+
`Mutations::` class, a class `@field` names and a type class's methods read it as
|
|
217
|
+
`resolve_info`:
|
|
218
|
+
|
|
219
|
+
| | |
|
|
220
|
+
| --- | --- |
|
|
221
|
+
| `field_name`, `field`, `return_type` | the field being resolved |
|
|
222
|
+
| `parent_type` | the type it belongs to |
|
|
223
|
+
| `object`, `arguments`, `context` | what it resolves with |
|
|
224
|
+
| `path` | `["posts", 0, "author"]` |
|
|
225
|
+
| `lookahead` | graphql-ruby's [`Lookahead`](https://graphql-ruby.org/queries/lookahead.html): what the client selects below the field (`lookahead.selects?(:author)`) |
|
|
226
|
+
| `enhance_builder(relation, scopes: [])` | `relation` with the field's query directives applied |
|
|
227
|
+
|
|
228
|
+
`enhance_builder` lets a class build the query no directive could, and still honour
|
|
229
|
+
what the field declares: its `@builder` classes, and the argument directives the
|
|
230
|
+
client used (`@eq`, `@where`, `@in`, `@like`, `@orderBy`, `@whereConditions`, ...),
|
|
231
|
+
as `@all` and `@paginate` apply them. `scopes:` adds model scopes.
|
|
232
|
+
|
|
233
|
+
```graphql
|
|
234
|
+
type Query {
|
|
235
|
+
myPosts(title: String @like, orderBy: [PostOrderByClause!] @orderBy): [Post!]! @guard
|
|
236
|
+
}
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
```ruby
|
|
240
|
+
class Resolvers::MyPosts < Resolvers::BaseResolver
|
|
241
|
+
def resolve(**)
|
|
242
|
+
resolve_info.enhance_builder(context[:current_user].posts, scopes: [:recent])
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
### Fields of other types
|
|
248
|
+
|
|
249
|
+
A field of any other type resolves with the default reader: `Post.commentsCount`
|
|
250
|
+
reads `post.comments_count`. When its value takes more than reading an attribute
|
|
251
|
+
or calling a method ([`@method`](directives.md#method)), name a class with
|
|
252
|
+
`@field`. The class is a resolver like the ones above, and its `object` is the
|
|
253
|
+
**parent**: the record the field belongs to (PHP Lighthouse's `$root`). It
|
|
254
|
+
resolves wherever the type appears in a response: in a list, under a
|
|
255
|
+
relationship, or under another class's result.
|
|
256
|
+
|
|
257
|
+
```graphql
|
|
258
|
+
type Post {
|
|
259
|
+
tagsCount: Int! @field(resolver: "Posts::TagsCount")
|
|
260
|
+
excerpt(length: Int = 80): String @field(resolver: "Posts::Excerpt")
|
|
261
|
+
}
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
```ruby
|
|
265
|
+
# app/graphql/resolvers/posts/tags_count.rb
|
|
266
|
+
module Resolvers
|
|
267
|
+
module Posts
|
|
268
|
+
class TagsCount < BaseResolver
|
|
269
|
+
def resolve
|
|
270
|
+
dataload_association(object, :tags).size
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# app/graphql/resolvers/posts/excerpt.rb
|
|
277
|
+
module Resolvers
|
|
278
|
+
module Posts
|
|
279
|
+
class Excerpt < BaseResolver
|
|
280
|
+
def resolve(length:)
|
|
281
|
+
object.body&.truncate(length, separator: ' ')
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
```graphql
|
|
289
|
+
{
|
|
290
|
+
posts(first: 10) { data { title tagsCount excerpt(length: 40) } }
|
|
291
|
+
}
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
The field's arguments arrive as keyword arguments, with the SDL's default when the
|
|
295
|
+
client leaves one out, as on a root field. The class runs once per parent, so load
|
|
296
|
+
anything beyond the parent's own columns through graphql-ruby's dataloader
|
|
297
|
+
(`dataload_association`, `dataload_record`, or a `GraphQL::Dataloader::Source` of
|
|
298
|
+
your own). Then listing ten posts runs one query for their tags, not ten.
|
|
299
|
+
|
|
300
|
+
A field can also resolve with a method of its type's class, as in a graphql-ruby
|
|
301
|
+
app. See [type classes](#type-classes), next: they suit a field that only the type
|
|
302
|
+
needs, and a class named by `@field` suits one with its own logic, its own
|
|
303
|
+
arguments, or that you want to find in the schema.
|
|
304
|
+
|
|
305
|
+
On these types the name is looked up in the resolvers namespace first, a nested
|
|
306
|
+
name too, so `Posts::TagsCount` is `Resolvers::Posts::TagsCount`, kept in
|
|
307
|
+
`app/graphql/resolvers/posts/`. A folder per type keeps these classes apart from
|
|
308
|
+
the `Query` ones. Name it in the plural (`Posts`): a `Resolvers::Post` module would
|
|
309
|
+
hide the `Post` model from code written inside `module Resolvers`. When the
|
|
310
|
+
resolvers namespace has no such class, the name is looked up in
|
|
311
|
+
[`resolver_namespaces`](configuration.md#resolver_namespaces), and a full name
|
|
312
|
+
(`Resolvers::Posts::TagsCount`) works as well. The schema's `base_types` are not
|
|
313
|
+
enforced here, but inheriting from `Resolvers::BaseResolver` still gives the class
|
|
314
|
+
what every resolver shares.
|
|
315
|
+
|
|
316
|
+
### Type classes
|
|
317
|
+
|
|
318
|
+
graphql-ruby keeps the Ruby side of each object type in `app/graphql/types`:
|
|
319
|
+
`Types::PostType`, inheriting from `Types::BaseObject`, as `rails g graphql:object
|
|
320
|
+
Post` writes it. Lighthouse keeps that structure. A type class that includes `Lighthouse::GraphQL::Type` is handed to
|
|
321
|
+
Lighthouse: the SDL still declares the type and its fields, and the class says how
|
|
322
|
+
the fields with no resolving directive resolve. A field calls the method of its
|
|
323
|
+
snake_case name, when the class defines it. Fields it does not define are read off
|
|
324
|
+
the object, as before, and fields with a resolving directive keep it.
|
|
325
|
+
|
|
326
|
+
That makes a type that is not a model as easy to resolve as a model's: the parent
|
|
327
|
+
returns any object (a Hash, a Struct, a client's response), and the type's class
|
|
328
|
+
reads or computes each field from it.
|
|
329
|
+
|
|
330
|
+
```graphql
|
|
331
|
+
type Post {
|
|
332
|
+
title: String!
|
|
333
|
+
readingTime: ReadingTime!
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
type ReadingTime {
|
|
337
|
+
words: Int!
|
|
338
|
+
minutes(wordsPerMinute: Int = 200): Int!
|
|
339
|
+
}
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
```ruby
|
|
343
|
+
# app/graphql/types/post_type.rb
|
|
344
|
+
module Types
|
|
345
|
+
class PostType < BaseObject
|
|
346
|
+
include Lighthouse::GraphQL::Type
|
|
347
|
+
|
|
348
|
+
def reading_time
|
|
349
|
+
{ words: object.body.to_s.split.size }
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# app/graphql/types/reading_time_type.rb
|
|
355
|
+
module Types
|
|
356
|
+
class ReadingTimeType < BaseObject
|
|
357
|
+
include Lighthouse::GraphQL::Type
|
|
358
|
+
|
|
359
|
+
def minutes(words_per_minute:)
|
|
360
|
+
[(object[:words] / words_per_minute.to_f).ceil, 1].max
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
`Post.title` is the model's column. `Post.readingTime` calls
|
|
367
|
+
`Types::PostType#reading_time`. `ReadingTime.words` is read off the Hash it
|
|
368
|
+
returns, and `ReadingTime.minutes` calls `Types::ReadingTimeType#minutes`.
|
|
369
|
+
|
|
370
|
+
As in graphql-ruby, the class is instantiated per parent, so a method has:
|
|
371
|
+
|
|
372
|
+
- `object`: the parent, here the post;
|
|
373
|
+
- `context`: the query's context;
|
|
374
|
+
- the field's arguments, as snake_case keywords, with the SDL's default when the
|
|
375
|
+
client leaves one out;
|
|
376
|
+
- graphql-ruby's `dataload`, `dataload_record` and `dataload_association`, to load
|
|
377
|
+
anything beyond the parent's own columns without N+1.
|
|
378
|
+
|
|
379
|
+
Wrapping directives (`@guard`, `@can*`, `@cache`) on the field run around the
|
|
380
|
+
method. A method every `GraphQL::Schema::Object` has (`object`, `context`) never
|
|
381
|
+
resolves a field.
|
|
382
|
+
|
|
383
|
+
#### The model
|
|
384
|
+
|
|
385
|
+
The type's model is the SDL's to say, as in PHP Lighthouse: a type named after its
|
|
386
|
+
model needs nothing, and [`@model`](directives.md#model) maps one that is not:
|
|
387
|
+
|
|
388
|
+
```graphql
|
|
389
|
+
type Article @model(class: "Post") {
|
|
390
|
+
title: String!
|
|
391
|
+
headline: String!
|
|
392
|
+
}
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
`Types::ArticleType`'s methods then only ever run on a `Post`: a parent of another
|
|
396
|
+
class (a Hash a resolver returned by mistake, another model) is reported through
|
|
397
|
+
[`on_error`](configuration.md#on_error), which raises in test, and the field
|
|
398
|
+
resolves to `null`. `@model` also gives `@all`, `@find`, `@paginate`, `@create`... on
|
|
399
|
+
fields returning `Article` their model, and resolves a `Post` in a union or
|
|
400
|
+
interface to `Article` ([types.md](types.md#the-default)).
|
|
401
|
+
|
|
402
|
+
#### Opting in
|
|
403
|
+
|
|
404
|
+
The class is the type's name plus `Type`, looked up in `namespaces[:types]`
|
|
405
|
+
(default `['Types']`) when the schema is built. Only a class that includes
|
|
406
|
+
`Lighthouse::GraphQL::Type` counts: any other `Types::PostType`, such as the ones
|
|
407
|
+
a graphql-ruby app already has, is left alone, and its methods resolve nothing.
|
|
408
|
+
Include the concern in `Types::BaseObject` to hand every type class over at once.
|
|
409
|
+
|
|
410
|
+
A class that includes the concern must inherit from the schema's
|
|
411
|
+
`base_types[:object]` when one is given, or the schema does not build. The concern
|
|
412
|
+
also hands over the classes of unions, interfaces and scalars, which pick the type
|
|
413
|
+
of a union's values or coerce a scalar's: see [types.md](types.md). It is not the schema's type: it only lends its
|
|
414
|
+
methods. Fields are declared in the SDL, so `field` declarations in it are ignored,
|
|
415
|
+
and graphql-ruby's type-level `authorized?` does not run; authorize with
|
|
416
|
+
[`@can*`](authorization.md). `Query`, `Mutation` and `Subscription` fields resolve
|
|
417
|
+
with their own classes, [by convention](#resolution-by-convention), not with a type
|
|
418
|
+
class.
|
|
109
419
|
|
|
110
420
|
### `@spread` and `@inject`
|
|
111
421
|
|
|
@@ -166,26 +476,29 @@ end
|
|
|
166
476
|
## Generators
|
|
167
477
|
|
|
168
478
|
```shell
|
|
169
|
-
bin/rails generate lighthouse:query LatestPosts # app/graphql/
|
|
479
|
+
bin/rails generate lighthouse:query LatestPosts # app/graphql/resolvers/latest_posts.rb
|
|
170
480
|
bin/rails generate lighthouse:mutation PublishPost # app/graphql/mutations/publish_post.rb
|
|
171
481
|
bin/rails generate lighthouse:subscription PostUpdated # app/graphql/subscriptions/post_updated.rb
|
|
172
482
|
bin/rails generate lighthouse:builder Published # app/graphql/builders/published.rb
|
|
173
483
|
bin/rails generate lighthouse:validator CreatePostInput # app/graphql/validators/create_post_input_validator.rb
|
|
484
|
+
bin/rails generate graphql:object Post # a type class: then include Lighthouse::GraphQL::Type
|
|
174
485
|
```
|
|
175
486
|
|
|
176
|
-
Each
|
|
177
|
-
|
|
178
|
-
`
|
|
179
|
-
generators
|
|
180
|
-
|
|
181
|
-
file, and Rails asks before overwriting it.
|
|
487
|
+
Each writes the class with its contract, into the first namespace configured for its
|
|
488
|
+
kind (or `--namespace=…`), inheriting from the namespace's base class. Type classes
|
|
489
|
+
come from graphql-ruby's own `graphql:object`, `graphql:scalar`, `graphql:union` and
|
|
490
|
+
`graphql:interface`. [generators.md](generators.md) has what each generator writes,
|
|
491
|
+
and which of graphql-ruby's fit a Lighthouse app.
|
|
182
492
|
|
|
183
493
|
## Changing the namespaces
|
|
184
494
|
|
|
185
495
|
```ruby
|
|
186
496
|
Lighthouse.configure do |config|
|
|
187
|
-
config.namespaces = { queries: ['Api::
|
|
497
|
+
config.namespaces = { queries: ['Api::Resolvers'], mutations: ['Api::Mutations'] }
|
|
188
498
|
end
|
|
189
499
|
```
|
|
190
500
|
|
|
191
501
|
Kinds you leave out keep their defaults. Each kind takes a list, tried in order.
|
|
502
|
+
The `queries` kind is the classes of `Query` fields. Before 0.4.0 it defaulted to
|
|
503
|
+
`Queries`; an app keeping its classes in `app/graphql/queries` sets
|
|
504
|
+
`config.namespaces = { queries: ['Queries'] }`.
|
data/docs/security.md
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
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.
|
|
211
|
+
|
|
212
|
+
## Rate limiting
|
|
213
|
+
|
|
214
|
+
[`@throttle`](directives.md#throttle) caps how often one client may call a field —
|
|
215
|
+
a login, a search, an expensive mutation:
|
|
216
|
+
|
|
217
|
+
```graphql
|
|
218
|
+
type Mutation {
|
|
219
|
+
login(email: String!, password: String!): User @throttle(maxAttempts: 5, decayMinutes: 1)
|
|
220
|
+
}
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Attempts are counted in [`cache_store`](configuration.md#cache_store), so use a store
|
|
224
|
+
the app servers share (Redis, Solid Cache) in production; `:null_store` counts
|
|
225
|
+
nothing. A client is the signed-in user, else the `:remote_ip` the controller concern
|
|
226
|
+
puts in the context (override `graphql_context` to set another). A refused call never
|
|
227
|
+
reaches `@guard` or the resolver.
|