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.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +182 -1
  3. data/README.md +18 -7
  4. data/docs/best-practices.md +16 -6
  5. data/docs/configuration.md +33 -8
  6. data/docs/directives.md +318 -10
  7. data/docs/events.md +3 -1
  8. data/docs/federation.md +3 -2
  9. data/docs/generators.md +82 -0
  10. data/docs/getting-started.md +66 -32
  11. data/docs/motivation.md +3 -2
  12. data/docs/relationships.md +42 -0
  13. data/docs/relay.md +137 -0
  14. data/docs/resolvers.md +338 -25
  15. data/docs/security.md +227 -0
  16. data/docs/subscriptions.md +60 -37
  17. data/docs/types.md +219 -0
  18. data/docs/uploads.md +80 -0
  19. data/lib/generators/lighthouse/base_classes.rb +70 -0
  20. data/lib/generators/lighthouse/class_generator.rb +19 -8
  21. data/lib/generators/lighthouse/install/install_generator.rb +96 -20
  22. data/lib/generators/lighthouse/install/templates/initializer.rb.tt +19 -10
  23. data/lib/generators/lighthouse/install/templates/schema.rb.tt +21 -2
  24. data/lib/generators/lighthouse/mutation_generator.rb +5 -4
  25. data/lib/generators/lighthouse/query_generator.rb +5 -4
  26. data/lib/generators/lighthouse/subscription_generator.rb +21 -16
  27. data/lib/lighthouse/async_mutation_job.rb +17 -0
  28. data/lib/lighthouse/complexity.rb +175 -0
  29. data/lib/lighthouse/complexity_analyzer.rb +56 -0
  30. data/lib/lighthouse/config.rb +16 -4
  31. data/lib/lighthouse/controller.rb +27 -9
  32. data/lib/lighthouse/dataloader_sources/preload_loader.rb +16 -0
  33. data/lib/lighthouse/dataloader_sources/relation_counts_loader.rb +46 -0
  34. data/lib/lighthouse/default_resolve_dispatcher.rb +26 -0
  35. data/lib/lighthouse/directive_resolvers/all_resolver.rb +1 -1
  36. data/lib/lighthouse/directive_resolvers/argument_binding.rb +61 -0
  37. data/lib/lighthouse/directive_resolvers/argument_transforms_resolver.rb +220 -0
  38. data/lib/lighthouse/directive_resolvers/association_resolvers.rb +2 -2
  39. data/lib/lighthouse/directive_resolvers/async_resolver.rb +90 -0
  40. data/lib/lighthouse/directive_resolvers/base.rb +63 -14
  41. data/lib/lighthouse/directive_resolvers/builder_applier.rb +89 -19
  42. data/lib/lighthouse/directive_resolvers/can_resolvers.rb +1 -1
  43. data/lib/lighthouse/directive_resolvers/convention_resolver.rb +16 -5
  44. data/lib/lighthouse/directive_resolvers/convert_empty_strings_resolver.rb +1 -1
  45. data/lib/lighthouse/directive_resolvers/count_resolver.rb +1 -1
  46. data/lib/lighthouse/directive_resolvers/field_resolver.rb +35 -7
  47. data/lib/lighthouse/directive_resolvers/first_resolver.rb +1 -1
  48. data/lib/lighthouse/directive_resolvers/global_id_resolver.rb +57 -0
  49. data/lib/lighthouse/directive_resolvers/lazy_load_resolver.rb +61 -0
  50. data/lib/lighthouse/directive_resolvers/limit_resolver.rb +59 -0
  51. data/lib/lighthouse/directive_resolvers/method_resolver.rb +38 -3
  52. data/lib/lighthouse/directive_resolvers/model_mutation_resolver.rb +1 -1
  53. data/lib/lighthouse/directive_resolvers/namespaced_resolver.rb +31 -0
  54. data/lib/lighthouse/directive_resolvers/paginate_resolver.rb +1 -1
  55. data/lib/lighthouse/directive_resolvers/resolution.rb +19 -4
  56. data/lib/lighthouse/directive_resolvers/subscription_resolver.rb +14 -5
  57. data/lib/lighthouse/directive_resolvers/throttle_resolver.rb +77 -0
  58. data/lib/lighthouse/directive_resolvers/type_method_resolver.rb +81 -0
  59. data/lib/lighthouse/directive_resolvers/with_count_resolver.rb +43 -0
  60. data/lib/lighthouse/directive_resolvers/with_resolver.rb +42 -0
  61. data/lib/lighthouse/directives/arguments/more_filters.rb +215 -0
  62. data/lib/lighthouse/directives/definitions.rb +52 -0
  63. data/lib/lighthouse/directives/model_directive.rb +131 -0
  64. data/lib/lighthouse/directives/node_directive.rb +134 -0
  65. data/lib/lighthouse/directives/pagination_manipulator.rb +1 -1
  66. data/lib/lighthouse/directives/registry.rb +21 -20
  67. data/lib/lighthouse/directives/visibility_directive.rb +71 -0
  68. data/lib/lighthouse/enum_directive.rb +41 -0
  69. data/lib/lighthouse/events/execution_trace.rb +2 -1
  70. data/lib/lighthouse/events/lifecycle.rb +3 -1
  71. data/lib/lighthouse/field_resolver_stack.rb +59 -11
  72. data/lib/lighthouse/generated_resolvers.rb +13 -8
  73. data/lib/lighthouse/global_id.rb +31 -0
  74. data/lib/lighthouse/graphql/version.rb +1 -1
  75. data/lib/lighthouse/multipart_request.rb +43 -0
  76. data/lib/lighthouse/node_registry.rb +68 -0
  77. data/lib/lighthouse/pagination.rb +3 -0
  78. data/lib/lighthouse/rb_lighthouse.rb +36 -0
  79. data/lib/lighthouse/relation_counts.rb +29 -0
  80. data/lib/lighthouse/resolve_info.rb +80 -0
  81. data/lib/lighthouse/resolver_bases.rb +68 -0
  82. data/lib/lighthouse/resolver_dispatch.rb +36 -11
  83. data/lib/lighthouse/scalar_coercion.rb +72 -0
  84. data/lib/lighthouse/schema_cache.rb +2 -1
  85. data/lib/lighthouse/schema_factory.rb +0 -1
  86. data/lib/lighthouse/schema_generator.rb +21 -12
  87. data/lib/lighthouse/schema_implementation.rb +4 -8
  88. data/lib/lighthouse/sdl_loader.rb +0 -2
  89. data/lib/lighthouse/support/environment.rb +13 -0
  90. data/lib/lighthouse/support/model_resolver.rb +8 -6
  91. data/lib/lighthouse/support/preload.rb +49 -0
  92. data/lib/lighthouse/type.rb +103 -0
  93. data/lib/lighthouse/type_resolution.rb +150 -0
  94. data/lib/lighthouse/upload.rb +26 -0
  95. data/lib/lighthouse/upload_store.rb +32 -0
  96. data/sig/lighthouse/directives.rbs +3 -2
  97. data/sig/lighthouse/graphql.rbs +29 -0
  98. data/sig/lighthouse.rbs +18 -0
  99. metadata +43 -1
@@ -6,9 +6,12 @@ happens. Lighthouse builds on [graphql-ruby's subscriptions](https://graphql-rub
6
6
  and keeps the shape of [PHP Lighthouse's](https://lighthouse-php.com/master/subscriptions/getting-started.html):
7
7
 
8
8
  - a `Subscription` field is declared in the SDL, and **resolves by convention** to a class;
9
- - that class is a plain graphql-ruby [`GraphQL::Schema::Subscription`](https://graphql-ruby.org/subscriptions/subscription_classes.html),
10
- as a query is a `GraphQL::Schema::Resolver` and a mutation a `GraphQL::Schema::Mutation`
11
- (see [resolvers.md](resolvers.md));
9
+ - that class inherits from `Subscriptions::BaseSubscription`, a graphql-ruby
10
+ [`GraphQL::Schema::Subscription`](https://graphql-ruby.org/subscriptions/subscription_classes.html),
11
+ as a `Query` field's class is a `Resolvers::BaseResolver` and a mutation's a
12
+ `Mutations::BaseMutation` (see [resolvers.md](resolvers.md#base-classes)). The base
13
+ includes [Lighthouse's concern](#the-subscription-concern): PHP
14
+ Lighthouse's model, one topic per field and a `filter?`;
12
15
  - a mutation announces what it did with [`@broadcast`](#broadcast-from-a-mutation).
13
16
 
14
17
  ```graphql
@@ -23,10 +26,10 @@ type Mutation {
23
26
 
24
27
  ```ruby
25
28
  # app/graphql/subscriptions/post_updated.rb (rails generate lighthouse:subscription PostUpdated)
26
- class Subscriptions::PostUpdated < GraphQL::Schema::Subscription
27
- # Runs for each update: skip the subscribers who may not see this post.
28
- def update(**)
29
- object.published? || object.author == context[:current_user] ? object : NO_UPDATE
29
+ class Subscriptions::PostUpdated < Subscriptions::BaseSubscription
30
+ # For each broadcast: skip the subscribers who may not see this post.
31
+ def filter?(post, **)
32
+ post.published? || post.author == context[:current_user]
30
33
  end
31
34
  end
32
35
  ```
@@ -94,12 +97,14 @@ type Subscription {
94
97
  }
95
98
  ```
96
99
 
97
- For anything more, write the class (a field with no class of its own behaves like
98
- the concern's field-wide topic with no `filter?`). `Subscription.postUpdated` resolves to
100
+ For anything more, write the class. (A field with no class of its own resolves with
101
+ the schema's `base_types[:subscription]`, else with a built-in class that includes
102
+ the concern: one topic for the field, no `filter?`.) `Subscription.postUpdated` resolves to
99
103
  `Subscriptions::PostUpdated`, looked up in `Config.namespaces[:subscriptions]`
100
104
  (default `['Subscriptions']`) each time it is needed, so code reloading works.
101
105
  `rails generate lighthouse:subscription PostUpdated` writes it. It inherits from
102
- `GraphQL::Schema::Subscription`: anything you know from graphql-ruby applies.
106
+ `Subscriptions::BaseSubscription`, a `GraphQL::Schema::Subscription`: anything you
107
+ know from graphql-ruby applies.
103
108
 
104
109
  Like a query or a mutation class, it declares neither the arguments nor the return
105
110
  type: the SDL does. The field's arguments arrive as `snake_case` keywords.
@@ -109,7 +114,10 @@ type: the SDL does. The field's arguments arrive as `snake_case` keywords.
109
114
  graphql-ruby calls the class twice in a subscription's life: once for the
110
115
  **subscription request** (the client's `subscription { ... }`), and then once for
111
116
  **each update** (`@broadcast`, or `schema.subscriptions.trigger`). All hooks are
112
- optional.
117
+ optional. These are graphql-ruby's own; the concern `BaseSubscription` includes
118
+ implements `authorized?` and `update` with PHP Lighthouse's hooks
119
+ ([below](#the-subscription-concern)), and a class that defines them itself
120
+ keeps graphql-ruby's behavior.
113
121
 
114
122
  | Hook | Called | Default |
115
123
  | --- | --- | --- |
@@ -135,7 +143,7 @@ optional.
135
143
  field comes back `null`, with no error.
136
144
 
137
145
  ```ruby
138
- class Subscriptions::OrderShipped < GraphQL::Schema::Subscription
146
+ class Subscriptions::OrderShipped < Subscriptions::BaseSubscription
139
147
  def authorized?(**)
140
148
  raise GraphQL::ExecutionError, 'Sign in to follow your orders.' unless context[:current_user]
141
149
 
@@ -145,8 +153,6 @@ class Subscriptions::OrderShipped < GraphQL::Schema::Subscription
145
153
  def update(**)
146
154
  object.user_id == context[:current_user].id ? object : NO_UPDATE
147
155
  end
148
-
149
- def self.trigger_arguments(_order, _context) = {}
150
156
  end
151
157
  ```
152
158
 
@@ -164,22 +170,32 @@ If you know PHP Lighthouse's `GraphQLSubscription`, this is where its methods we
164
170
  One real difference is the **topic**. PHP Lighthouse puts every subscriber of a
165
171
  field on one topic and `filter` decides who is updated. graphql-ruby's default
166
172
  topic includes the arguments, so a trigger reaches only the subscribers whose
167
- arguments *equal* its own. That is why `@broadcast` asks a plain class for its
168
- `trigger_arguments`; see [below](#topics-trigger-arguments-and-filtering).
173
+ arguments *equal* its own. The concern in `BaseSubscription` takes PHP Lighthouse's
174
+ topic; a class that keeps graphql-ruby's gives `@broadcast` its `trigger_arguments`.
175
+ See [below](#topics-trigger-arguments-and-filtering).
169
176
 
170
- ## Lighthouse's subscription concern
177
+ ## The subscription concern
171
178
 
172
- If you prefer PHP Lighthouse's model, with one topic per field, `filter?` to narrow
173
- it and `resolve_payload` for the result, include `Lighthouse::GraphQL::Subscription`
174
- in the class, as `include Lighthouse::GraphQL::Controller` gives a controller its
175
- `execute`. The class is still a plain `GraphQL::Schema::Subscription`, and declares
176
- its arguments as one would in graphql-ruby (or leaves them to the SDL).
177
- `rails generate lighthouse:subscription PostUpdated --lighthouse` writes it:
179
+ `Lighthouse::GraphQL::Subscription` is PHP Lighthouse's model: one topic per field,
180
+ `filter?` to narrow it and `resolve_payload` for the result. It is included once, in
181
+ the base every subscription inherits from, as graphql-ruby's convention puts what
182
+ all classes of a kind share in their base:
178
183
 
179
184
  ```ruby
180
- class Subscriptions::PostUpdated < GraphQL::Schema::Subscription
181
- include Lighthouse::GraphQL::Subscription
185
+ # app/graphql/subscriptions/base_subscription.rb (rails generate lighthouse:install)
186
+ module Subscriptions
187
+ class BaseSubscription < GraphQL::Schema::Subscription
188
+ include Lighthouse::GraphQL::Subscription
189
+ end
190
+ end
191
+ ```
192
+
193
+ A subscription is still a plain `GraphQL::Schema::Subscription`, and declares its
194
+ arguments as one would in graphql-ruby (or leaves them to the SDL).
195
+ `rails generate lighthouse:subscription PostUpdated` writes one with the hooks:
182
196
 
197
+ ```ruby
198
+ class Subscriptions::PostUpdated < Subscriptions::BaseSubscription
183
199
  def filter?(post, author: nil)
184
200
  author.nil? || post.author_id.to_s == author.to_s
185
201
  end
@@ -205,17 +221,19 @@ optional.
205
221
  [broadcast section](#topics-trigger-arguments-and-filtering) explain.
206
222
  - Including it in a class that is not a `GraphQL::Schema::Subscription` raises a
207
223
  `Lighthouse::DefinitionError`.
224
+ - To keep graphql-ruby's model for every subscription, leave the `include` out of
225
+ `BaseSubscription`; the generator then writes classes with `authorized?` and
226
+ `update` instead.
208
227
  - **Precedence.** The concern sits between your class and
209
- `GraphQL::Schema::Subscription`, so what the class defines itself wins. A class
228
+ `GraphQL::Schema::Subscription` (it is in the base), so what the class defines
229
+ itself wins. A class
210
230
  with its own `update` (or `authorized?`) is called instead of the concern's, and
211
231
  its `filter?`, `resolve_payload` (or `authorize?`) are not, unless it calls
212
232
  `super`. `subscribe`, `unsubscribe`, `subscription_scope` and `ready?` are
213
233
  untouched, and combine freely with the hooks.
214
234
 
215
235
  ```ruby
216
- class Subscriptions::OrderShipped < GraphQL::Schema::Subscription
217
- include Lighthouse::GraphQL::Subscription
218
-
236
+ class Subscriptions::OrderShipped < Subscriptions::BaseSubscription
219
237
  def authorize?(**)
220
238
  context[:current_user].present?
221
239
  end
@@ -251,8 +269,9 @@ type Subscription {
251
269
 
252
270
  The name is looked up in `Config.namespaces[:subscriptions]` like the conventional
253
271
  one, so `class: "PostUpdated"` finds `Subscriptions::PostUpdated`, and a name with
254
- `::` is used as it is. A class that does not exist, or is not a
255
- `GraphQL::Schema::Subscription`, raises `Lighthouse::DefinitionError`.
272
+ `::` is used as it is. A class that does not exist, is not a
273
+ `GraphQL::Schema::Subscription`, or does not inherit from the schema's
274
+ `base_types[:subscription]` raises `Lighthouse::DefinitionError`.
256
275
  `@guard`, `@can*` and the other wrapping directives work on a subscription field
257
276
  as on any other.
258
277
 
@@ -303,7 +322,8 @@ arguments (a Hash, `{}` by default) for the `root` a mutation returned, and
303
322
  `subscription_scope` (graphql-ruby's way to give each tenant or user their own
304
323
  stream).
305
324
 
306
- With a plain `GraphQL::Schema::Subscription`:
325
+ When the topic includes the arguments (a class with `topic_by :arguments`, or under
326
+ a base that does not include the concern):
307
327
 
308
328
  - **A field without arguments works out of the box.** Every subscriber's arguments
309
329
  are `{}`, and so is the trigger's.
@@ -317,7 +337,9 @@ With a plain `GraphQL::Schema::Subscription`:
317
337
  ```
318
338
 
319
339
  ```ruby
320
- class Subscriptions::MessagePosted < GraphQL::Schema::Subscription
340
+ class Subscriptions::MessagePosted < Subscriptions::BaseSubscription
341
+ topic_by :arguments
342
+
321
343
  # The subscribers of `messagePosted(room: "<id>")`.
322
344
  def self.trigger_arguments(message, _context) = { room: message.room_id.to_s }
323
345
  end
@@ -336,11 +358,11 @@ With a plain `GraphQL::Schema::Subscription`:
336
358
  those subscribers are updated, so give such a class a `trigger_arguments` too if
337
359
  subscribers pass the argument.
338
360
  - **To have one trigger reach subscribers with different arguments** (the argument
339
- is a filter, not an identity), give the class one topic per field, as
340
- graphql-ruby documents, and let `update` return `NO_UPDATE`:
361
+ is a filter, not an identity) without the concern, give the class one topic per
362
+ field, as graphql-ruby documents, and let `update` return `NO_UPDATE`:
341
363
 
342
364
  ```ruby
343
- class Subscriptions::PostPublished < GraphQL::Schema::Subscription
365
+ class Subscriptions::PostPublished < Subscriptions::BaseSubscription # a base without the concern
344
366
  def self.topic_for(field:, scope:, **)
345
367
  GraphQL::Subscriptions::Serialize.dump_recursive([scope, field.graphql_name])
346
368
  end
@@ -417,7 +439,8 @@ the repository's own specs do, in `spec/support/in_memory_subscriptions.rb`.
417
439
  graphql-ruby also ships `GraphQL::Testing::MockActionCable` for the real
418
440
  ActionCable backend.
419
441
 
420
- The repository's `example/` app is a complete setup: plain subscription classes,
442
+ The repository's `example/` app is a complete setup: subscription classes under
443
+ `Subscriptions::BaseSubscription`,
421
444
  `GraphqlChannel`, `ApplicationCable::Connection` with token authentication, and
422
445
  specs that drive a mutation through `POST /graphql` and watch the update arrive on
423
446
  an ActionCable subscriber (`example/spec/subscriptions/delivery_spec.rb`).
data/docs/types.md ADDED
@@ -0,0 +1,219 @@
1
+ # Unions, Interfaces and Scalars
2
+
3
+ The SDL declares every type. Object types resolve their fields through
4
+ directives, the default reader, or a [type class](resolvers.md#type-classes).
5
+ Three more kinds need Ruby to say how their values work:
6
+
7
+ - a **union** or an **interface** needs to know which object type each value is;
8
+ - a custom **scalar** needs to know how to read its values from a query and write
9
+ them to a response.
10
+
11
+ Lighthouse answers both as [PHP Lighthouse](https://lighthouse-php.com/master/the-basics/types.html)
12
+ does by default, and lets you take over with a class, in graphql-ruby's
13
+ `app/graphql/types` structure, or with a directive.
14
+
15
+ ## Unions and interfaces
16
+
17
+ ```graphql
18
+ type Query {
19
+ search(term: String!): [SearchResult!]!
20
+ latestActivity: [Authored!]!
21
+ }
22
+
23
+ union SearchResult = Post | User
24
+
25
+ interface Authored {
26
+ id: ID!
27
+ author: User!
28
+ }
29
+
30
+ type Post implements Authored { id: ID! title: String! author: User! }
31
+ type Comment implements Authored { id: ID! body: String! author: User! }
32
+ ```
33
+
34
+ A resolver returns records, of any of the types:
35
+
36
+ ```ruby
37
+ class Resolvers::Search < Resolvers::BaseResolver
38
+ def resolve(term:)
39
+ Post.where('title LIKE ?', "%#{term}%").to_a + User.where('name LIKE ?', "%#{term}%").to_a
40
+ end
41
+ end
42
+ ```
43
+
44
+ ```graphql
45
+ {
46
+ search(term: "Grace") {
47
+ __typename
48
+ ... on Post { title }
49
+ ... on User { name }
50
+ }
51
+ }
52
+ ```
53
+
54
+ ### The default
55
+
56
+ Each value resolves to the first of these that is one of the union's (or the
57
+ interface's) types:
58
+
59
+ 1. a Hash's `__typename` (`{ __typename: 'Movie', title: 'Heat' }`);
60
+ 2. the type named after the value's class (a `Post` record is a `Post`), or after
61
+ one of its superclasses, so an `Admin < User` STI record is a `User` when no
62
+ `Admin` type exists;
63
+ 3. the type whose [`@model`](directives.md#model) names the value's model. With
64
+ `type Article @model(class: "Post")`, a `Post` record is an `Article`.
65
+
66
+ Most schemas need nothing else.
67
+
68
+ ### A type class
69
+
70
+ When the default is not enough, say when the values are Hashes from a search
71
+ service rather than records, write the union's class as graphql-ruby does
72
+ (`rails g graphql:union SearchResult`),
73
+ `Types::<Name>Type` including `Lighthouse::GraphQL::Type`, with `resolve_type`.
74
+ It returns a type name, or a type class, which stands for the SDL type of its
75
+ `graphql_name`:
76
+
77
+ ```ruby
78
+ # app/graphql/types/search_result_type.rb
79
+ module Types
80
+ class SearchResultType < BaseUnion
81
+ include Lighthouse::GraphQL::Type
82
+
83
+ def self.resolve_type(object, context)
84
+ return unless object.is_a?(Hash)
85
+
86
+ object['index'] == 'users' ? Types::UserType : 'Post'
87
+ end
88
+ end
89
+ end
90
+ ```
91
+
92
+ An interface's goes in graphql-ruby's `definition_methods`:
93
+
94
+ ```ruby
95
+ # app/graphql/types/authored_type.rb
96
+ module Types
97
+ module AuthoredType
98
+ include BaseInterface
99
+ include Lighthouse::GraphQL::Type
100
+
101
+ definition_methods do
102
+ def resolve_type(object, context)
103
+ object['index'] == 'comments' ? 'Comment' : 'Post' if object.is_a?(Hash)
104
+ end
105
+ end
106
+ end
107
+ end
108
+ ```
109
+
110
+ `nil` leaves the value to the default, so the class only handles the cases that
111
+ need it. The SDL still declares the members: `possible_types` in the class are
112
+ ignored.
113
+
114
+ ### `@union` and `@interface`
115
+
116
+ PHP Lighthouse's directives name the class in the schema instead. It has
117
+ `resolve_type(object, context)` or `call(object, context)`, and is looked up in the
118
+ types namespace (nested names too), then as written:
119
+
120
+ ```graphql
121
+ union SearchResult @union(resolveType: "SearchResultResolver") = Post | User
122
+ interface Authored @interface(resolveType: "AuthoredResolver") { id: ID! }
123
+ ```
124
+
125
+ The directive comes first, then the type class, then the default.
126
+
127
+ ### When nothing resolves a value
128
+
129
+ A value no step resolves, or one resolved to a type the union or interface does
130
+ not include, raises `Lighthouse::DefinitionError`. It is a server bug, not a client
131
+ error, and the message names the ways out:
132
+
133
+ ```text
134
+ Lighthouse could not tell the type of a Tag in the union SearchResult (Post | User):
135
+ name a type after its class, map one to it with @model(class: "Tag"), or resolve
136
+ it in Types::SearchResultType.resolve_type or with @union(resolveType:).
137
+ ```
138
+
139
+ A `@union` or `@interface` naming no class, or a type class of another kind than
140
+ its SDL type (a union class for an interface), fails when the schema is built.
141
+
142
+ ## Scalars
143
+
144
+ ```graphql
145
+ "An ISO 8601 date and time."
146
+ scalar DateTime
147
+
148
+ type Post {
149
+ createdAt: DateTime!
150
+ }
151
+
152
+ type Query {
153
+ latestActivity(since: DateTime): [Authored!]!
154
+ }
155
+ ```
156
+
157
+ Write the scalar's class with graphql-ruby's `rails g graphql:scalar DateTime`,
158
+ `Types::<Name>Type` including `Lighthouse::GraphQL::Type`:
159
+
160
+ ```ruby
161
+ # app/graphql/types/date_time_type.rb
162
+ module Types
163
+ class DateTimeType < BaseScalar
164
+ include Lighthouse::GraphQL::Type
165
+
166
+ def self.coerce_input(value, context)
167
+ Time.iso8601(value.to_s)
168
+ rescue ArgumentError
169
+ raise GraphQL::CoercionError, "#{value.inspect} is not an ISO 8601 date and time"
170
+ end
171
+
172
+ def self.coerce_result(value, context)
173
+ value.utc.iso8601
174
+ end
175
+ end
176
+ end
177
+ ```
178
+
179
+ - `coerce_input` reads an argument or variable. The resolver gets its result, here a
180
+ `Time`. Raising `GraphQL::CoercionError` (or returning `nil`) makes the argument
181
+ invalid, and the client gets the message.
182
+ - `coerce_result` writes a value a field returns, here `Post#created_at`.
183
+
184
+ `@scalar(class:)`, PHP Lighthouse's directive, names the class in the schema
185
+ instead, looked up in the types namespace, then as written:
186
+
187
+ ```graphql
188
+ scalar Money @scalar(class: "Money::GraphQLScalar")
189
+ ```
190
+
191
+ A scalar with neither passes its values through unchanged. A `@scalar` naming no
192
+ class with `coerce_input` and `coerce_result` fails when the schema is built.
193
+
194
+ ## Opting in, and base classes
195
+
196
+ Only a class including `Lighthouse::GraphQL::Type` counts: any other
197
+ `Types::SearchResultType` or `Types::DateTimeType`, such as the ones a graphql-ruby
198
+ app already has, is left alone. The concern can be included in a subclass of
199
+ `GraphQL::Schema::Object`, `Union` or `Scalar`, or in a module including
200
+ `GraphQL::Schema::Interface`.
201
+
202
+ The classes are looked up in [`namespaces[:types]`](configuration.md#namespaces)
203
+ (default `['Types']`). When the schema's `base_types` name a base for a kind,
204
+ graphql-ruby builds the SDL's types of that kind from it, and a type class of that
205
+ kind must inherit from it, or the schema does not build:
206
+
207
+ ```ruby
208
+ AppSchema = Lighthouse::GraphQL::RbLightHouse.get_schema(
209
+ options: {
210
+ base_types: {
211
+ object: Types::BaseObject,
212
+ union: Types::BaseUnion,
213
+ interface: Types::BaseInterface,
214
+ scalar: Types::BaseScalar
215
+ # resolver:, mutation:, subscription: (resolvers.md#handing-the-bases-to-the-schema)
216
+ }
217
+ }
218
+ )
219
+ ```
data/docs/uploads.md ADDED
@@ -0,0 +1,80 @@
1
+ # File Uploads
2
+
3
+ Clients send files with the [GraphQL multipart request specification](https://github.com/jaydenseric/graphql-multipart-request-spec),
4
+ which Apollo Upload Client, urql and most GraphQL clients speak: a multipart form with
5
+ the operation as JSON, a map of where each file goes, and the files.
6
+ `Lighthouse::GraphQL::Controller` reads it, so the endpoint needs nothing more.
7
+
8
+ ## The `Upload` scalar
9
+
10
+ Declare it, and use it for arguments:
11
+
12
+ ```graphql
13
+ "A file sent with a multipart request."
14
+ scalar Upload
15
+
16
+ type Mutation {
17
+ updateAvatar(avatar: Upload!): User! @update @inject(context: "current_user.id", name: "id")
18
+ }
19
+ ```
20
+
21
+ Lighthouse reads its values itself (`Lighthouse::GraphQL::Upload`): the resolver gets
22
+ the uploaded file, an `ActionDispatch::Http::UploadedFile`. A value that is not a file
23
+ makes the argument invalid. `Upload` is for arguments only, never a field's result.
24
+
25
+ An ActiveStorage attachment takes the file as it is: with `has_one_attached :avatar`
26
+ on `User`, the mutation above attaches it.
27
+
28
+ ## `@upload`
29
+
30
+ `@upload` stores the file first, and passes along what was stored instead:
31
+
32
+ ```graphql
33
+ type Mutation {
34
+ updateAvatar(avatar: Upload! @upload(path: "avatars")): User!
35
+ @guard
36
+ @update
37
+ @inject(context: "current_user.id", name: "id")
38
+ }
39
+ ```
40
+
41
+ By default the file becomes an ActiveStorage blob: on the service `disk:` names (the
42
+ default service without it), with its key under `path:`. The argument then holds the
43
+ blob's **signed id**, which an attachment attribute attaches (`user.avatar = signed_id`)
44
+ and `ActiveStorage::Blob.find_signed` finds. Lists of files are stored one by one.
45
+
46
+ `@upload` runs once `@guard`, the `@can*` family and `@validator` have let the call
47
+ through, so a refused call stores nothing.
48
+
49
+ To store files elsewhere, give [`upload_store`](configuration.md#upload_store) an
50
+ object with `call(file, disk:, path:, public:)`; the argument holds what it returns:
51
+
52
+ ```ruby
53
+ Lighthouse.configure do |config|
54
+ config.upload_store = ->(file, path: '/', **) { S3Uploader.put(file, prefix: path) } # returns the key
55
+ end
56
+ ```
57
+
58
+ ## The request
59
+
60
+ ```shell
61
+ curl localhost:3000/graphql \
62
+ -H 'Authorization: Bearer <token>' \
63
+ -F operations='{ "query": "mutation($avatar: Upload!) { updateAvatar(avatar: $avatar) { avatarUrl } }", "variables": { "avatar": null } }' \
64
+ -F map='{ "0": ["variables.avatar"] }' \
65
+ -F 0=@me.png
66
+ ```
67
+
68
+ A batch sends an array as `operations`, and maps files to `"0.variables.avatar"`,
69
+ `"1.variables.files.0"`, and so on.
70
+
71
+ In a request spec:
72
+
73
+ ```ruby
74
+ avatar = Rack::Test::UploadedFile.new('spec/fixtures/me.png', 'image/png')
75
+ post '/graphql', params: {
76
+ operations: { query: mutation, variables: { avatar: nil } }.to_json,
77
+ map: { '0' => ['variables.avatar'] }.to_json,
78
+ '0' => avatar
79
+ }
80
+ ```
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+ require 'lighthouse-graphql'
5
+
6
+ # The base class graphql-ruby's convention puts in each namespace, which the
7
+ # classes Lighthouse generates inherit from: Resolvers::BaseResolver,
8
+ # Mutations::BaseMutation and Subscriptions::BaseSubscription.
9
+ #
10
+ # graphql-ruby's `graphql:install` writes the first two (and every Types::Base*),
11
+ # and `lighthouse:install` runs it: Lighthouse does not write copies of them. It
12
+ # writes only the base graphql-ruby has none of, BaseSubscription, which
13
+ # includes Lighthouse's subscription concern. A generator that finds a base
14
+ # missing says how to get it.
15
+ #
16
+ # A base that already exists is never replaced, not even with --force: it holds
17
+ # the app's own code.
18
+ module Lighthouse::Generators::BaseClasses
19
+ NAMES = { queries: 'BaseResolver', mutations: 'BaseMutation', subscriptions: 'BaseSubscription' }.freeze
20
+
21
+ # The bases Lighthouse writes itself, graphql-ruby having none.
22
+ SOURCES = {
23
+ subscriptions: <<~RUBY
24
+ # What every class resolving a Subscription field shares. The concern is PHP
25
+ # Lighthouse's model: every subscriber of a field gets every broadcast, and the
26
+ # class's `filter?` decides who receives it. A subclass that defines
27
+ # graphql-ruby's own `authorized?` or `update` keeps them, and `topic_by
28
+ # :arguments` brings back graphql-ruby's exact-argument matching.
29
+ class BaseSubscription < GraphQL::Schema::Subscription
30
+ include Lighthouse::GraphQL::Subscription
31
+ end
32
+ RUBY
33
+ }.freeze
34
+
35
+ private
36
+
37
+ # The base class name for `kind` (`BaseResolver`), or nil for a kind without one.
38
+ def base_class_name(kind)
39
+ NAMES[kind]
40
+ end
41
+
42
+ # Write the base class of `kind` into `namespace` when Lighthouse owns it and
43
+ # it is missing; say how to get one graphql-ruby owns.
44
+ def write_base_class(kind, namespace)
45
+ return unless base_class_name(kind)
46
+
47
+ path = base_class_path(kind, namespace)
48
+ if File.exist?(File.join(destination_root, path))
49
+ say_status :skip, "#{path} already exists", :yellow
50
+ elsif SOURCES[kind]
51
+ create_file path, ruby_source(namespace.split('::'), SOURCES[kind])
52
+ else
53
+ say_status :missing, "#{path}: graphql-ruby writes it — run `bin/rails g lighthouse:install` (which runs graphql:install), " \
54
+ "or write #{namespace}::#{base_class_name(kind)} yourself", :red
55
+ end
56
+ end
57
+
58
+ def base_class_path(kind, namespace)
59
+ File.join('app/graphql', *namespace.split('::').map(&:underscore), "#{base_class_name(kind).underscore}.rb")
60
+ end
61
+
62
+ # Ruby source defining `body` inside the nested `modules`.
63
+ def ruby_source(modules, body)
64
+ lines = ['# frozen_string_literal: true', '']
65
+ modules.each_with_index { |mod, depth| lines << "#{' ' * depth}module #{mod}" }
66
+ body.each_line { |line| lines << (line.strip.empty? ? '' : "#{' ' * modules.size}#{line.chomp}") }
67
+ modules.size.times.reverse_each { |depth| lines << "#{' ' * depth}end" }
68
+ "#{lines.join("\n")}\n"
69
+ end
70
+ end
@@ -2,16 +2,25 @@
2
2
 
3
3
  require 'rails/generators'
4
4
  require 'lighthouse-graphql'
5
+ require_relative 'base_classes'
5
6
 
6
7
  # Base for the generators that scaffold the classes a schema refers to by
7
- # name — queries, mutations, builders and validators — each in its own
8
- # namespace (Config.namespaces) and with its own contract.
8
+ # name — resolvers, mutations, subscriptions, builders and validators — each in
9
+ # its own namespace (Config.namespaces) and with its own contract. A resolver,
10
+ # mutation or subscription inherits from its namespace's base class
11
+ # (Resolvers::BaseResolver, ...), written first when it is missing.
9
12
  class Lighthouse::Generators::ClassGenerator < Rails::Generators::NamedBase
13
+ include Lighthouse::Generators::BaseClasses
14
+
10
15
  hide!
11
16
 
12
17
  class_option :namespace, type: :string,
13
18
  desc: 'Namespace to generate into (default: the first of Config.namespaces for this kind)'
14
19
 
20
+ def create_base_class
21
+ write_base_class(kind, namespace_name)
22
+ end
23
+
15
24
  def create_class
16
25
  create_file File.join('app/graphql', *modules.map(&:underscore), "#{class_basename.underscore}.rb"), source
17
26
  end
@@ -22,7 +31,7 @@ class Lighthouse::Generators::ClassGenerator < Rails::Generators::NamedBase
22
31
 
23
32
  private
24
33
 
25
- # :queries, :mutations, :builders or :validators.
34
+ # :queries, :mutations, :subscriptions, :builders or :validators.
26
35
  def kind
27
36
  raise NotImplementedError
28
37
  end
@@ -52,10 +61,12 @@ class Lighthouse::Generators::ClassGenerator < Rails::Generators::NamedBase
52
61
  end
53
62
 
54
63
  def source
55
- lines = ['# frozen_string_literal: true', '']
56
- modules.each_with_index { |mod, depth| lines << "#{' ' * depth}module #{mod}" }
57
- body.each_line { |line| lines << (line.strip.empty? ? '' : "#{' ' * modules.size}#{line.chomp}") }
58
- modules.size.times.reverse_each { |depth| lines << "#{' ' * depth}end" }
59
- "#{lines.join("\n")}\n"
64
+ ruby_source(modules, body)
65
+ end
66
+
67
+ # The class a generated class inherits from: the namespace's base class,
68
+ # which Ruby finds from inside the namespace's modules.
69
+ def superclass_name
70
+ base_class_name(kind)
60
71
  end
61
72
  end