lighthouse-graphql 0.5.0 → 0.6.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +118 -1
  3. data/README.md +24 -16
  4. data/docs/caching.md +4 -3
  5. data/docs/configuration.md +1 -21
  6. data/docs/directives.md +14 -7
  7. data/docs/generators.md +41 -39
  8. data/docs/getting-started.md +88 -51
  9. data/docs/performance.md +2 -2
  10. data/docs/relationships.md +5 -4
  11. data/docs/relay.md +63 -0
  12. data/docs/resolvers.md +90 -37
  13. data/docs/security.md +64 -16
  14. data/docs/subscriptions.md +16 -9
  15. data/docs/types.md +16 -17
  16. data/docs/uploads.md +41 -14
  17. data/lib/generators/lighthouse/base_classes.rb +9 -14
  18. data/lib/generators/lighthouse/install/graphql_ruby_install.rb +113 -0
  19. data/lib/generators/lighthouse/install/install_generator.rb +112 -91
  20. data/lib/generators/lighthouse/install/templates/initializer.rb.tt +3 -9
  21. data/lib/lighthouse/caching.rb +9 -3
  22. data/lib/lighthouse/config.rb +6 -23
  23. data/lib/lighthouse/controller.rb +28 -5
  24. data/lib/lighthouse/directive_resolvers/argument_transforms_resolver.rb +18 -18
  25. data/lib/lighthouse/directive_resolvers/cache_resolvers.rb +1 -1
  26. data/lib/lighthouse/directive_resolvers/global_id_resolver.rb +3 -1
  27. data/lib/lighthouse/directive_resolvers/throttle_resolver.rb +85 -26
  28. data/lib/lighthouse/directive_resolvers/type_method_resolver.rb +12 -6
  29. data/lib/lighthouse/directives/arguments/more_filters.rb +1 -1
  30. data/lib/lighthouse/field_resolver_stack.rb +9 -6
  31. data/lib/lighthouse/global_id.rb +29 -3
  32. data/lib/lighthouse/graphql/version.rb +1 -1
  33. data/lib/lighthouse/graphql_ruby_patches.rb +45 -0
  34. data/lib/lighthouse/rb_lighthouse.rb +1 -0
  35. data/lib/lighthouse/resolver_bases.rb +18 -4
  36. data/lib/lighthouse/resolver_dispatch.rb +16 -1
  37. data/lib/lighthouse/root_types.rb +117 -0
  38. data/lib/lighthouse/schema_generator.rb +4 -3
  39. data/lib/lighthouse/upload.rb +4 -2
  40. data/lib/lighthouse/upload_store.rb +39 -14
  41. data/sig/lighthouse.rbs +0 -10
  42. metadata +3 -4
  43. data/lib/generators/lighthouse/install/templates/schema.rb.tt +0 -29
  44. data/lib/generators/lighthouse/mutation_generator.rb +0 -31
  45. data/lib/generators/lighthouse/query_generator.rb +0 -30
data/docs/security.md CHANGED
@@ -10,20 +10,24 @@ Lighthouse works the same way on top of webonyx's validation rules.
10
10
 
11
11
  ## Setting the limits
12
12
 
13
- The limits are graphql-ruby's own schema settings. Set them on the schema
14
- Lighthouse builds:
13
+ The limits are graphql-ruby's own schema settings. Set them in the body of the
14
+ schema class, as in any graphql-ruby schema (graphql-ruby's install writes
15
+ `max_depth(15)` there):
15
16
 
16
17
  ```ruby
17
18
  # app/graphql/app_schema.rb
18
- AppSchema = Lighthouse::GraphQL::RbLightHouse.get_schema(options: { using: { GraphQL::Dataloader => {} } })
19
+ class AppSchema < Lighthouse::GraphQL::RbLightHouse.get_schema(options: { base_types: { object: Types::BaseObject } })
20
+ use GraphQL::Dataloader
19
21
 
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?
22
+ max_complexity(300) # refuse queries that score over 300
23
+ max_depth(12) # refuse queries nested deeper than 12 fields
24
+ disable_introspection_entry_points if Rails.env.production?
25
+ end
23
26
  ```
24
27
 
25
- You can also set them on a base schema class you hand to `get_schema` with
26
- `options: { base_schema: MyBaseSchema }`.
28
+ A schema assigned to a constant (`AppSchema = get_schema(...)`) takes them as calls
29
+ on it (`AppSchema.max_complexity(300)`). You can also set them on a base schema class
30
+ you hand to `get_schema` with `options: { base_schema: MyBaseSchema }`.
27
31
 
28
32
  A query over the limit isn't executed. Its response holds only the error:
29
33
 
@@ -211,17 +215,61 @@ GraphQL::Analysis.analyze_query(query, [GraphQL::Analysis::QueryComplexity]).fir
211
215
 
212
216
  ## Rate limiting
213
217
 
214
- [`@throttle`](directives.md#throttle) caps how often one client may call a field —
215
- a login, a search, an expensive mutation:
218
+ Rails' own [`rate_limit`](https://api.rubyonrails.org/classes/ActionController/RateLimiting/ClassMethods.html)
219
+ limits the endpoint: every operation counts, and past the limit Rails answers 429.
220
+ [`@throttle`](directives.md#throttle) is the same limit on one field — a login, a search,
221
+ an expensive mutation — beside it:
222
+
223
+ ```ruby
224
+ class GraphqlController < ApplicationController
225
+ include Lighthouse::GraphQL::Controller
226
+
227
+ lighthouse_schema AppSchema
228
+ rate_limit to: 600, within: 1.minute, name: 'endpoint'
229
+
230
+ private
231
+
232
+ def too_many_requests
233
+ render json: { errors: [{ message: 'Too many requests. Try again later.' }] }, status: :too_many_requests
234
+ end
235
+ end
236
+ ```
216
237
 
217
238
  ```graphql
218
239
  type Mutation {
219
- login(email: String!, password: String!): User @throttle(maxAttempts: 5, decayMinutes: 1)
240
+ login(email: String!, password: String!): User @throttle(maxAttempts: 5)
241
+ createTag(name: String!): Tag! @throttle(maxAttempts: 20, by: "current_user.id", with: "too_many_requests") @guard @create
220
242
  }
221
243
  ```
222
244
 
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.
245
+ `@throttle` counts as `rate_limit` does, under its key (`rate-limit:<scope>:<name>:<by>`):
246
+
247
+ - **Where.** In the controller's `cache_store`: `config.action_controller.cache_store`,
248
+ or `Rails.cache` when the app sets none (a controller can set its own with
249
+ `self.cache_store =`). Use a store the app servers share (Redis, Solid Cache) in
250
+ production; `:null_store` counts nothing.
251
+ - **Who.** The client's IP, as `rate_limit`'s default. `by:` reads another value off the
252
+ context, as `@inject` does (`by: "current_user.id"`); when it is `nil`, as for a guest,
253
+ those calls count together, as in Rails.
254
+ - **What counts together.** Each field counts under its own name (`Query.login`), apart
255
+ from the endpoint's `rate_limit` (`endpoint`). Give fields, or a field and a
256
+ controller's `rate_limit`, the same `name:` to count them together; `prefix:` is
257
+ `rate_limit`'s `scope:` (the controller's path by default).
258
+ - **Past the limit.** As `rate_limit`'s default, `ActionController::TooManyRequests` is
259
+ raised, and Rails answers the request with a 429 (in development too); on Rails 7.2,
260
+ which has no such error, `head :too_many_requests`. With `with:`, the controller's
261
+ method runs instead, as `rate_limit`'s `with:`. If it renders or redirects, the request
262
+ ends with that response and the operation stops there, as when a `before_action`
263
+ renders. If it returns, the call goes through. If it raises a `GraphQL::ExecutionError`,
264
+ only that field fails, and the rest of the operation is answered:
265
+
266
+ ```ruby
267
+ def rate_limited = raise(GraphQL::ExecutionError, 'Too many requests. Try again later.')
268
+ ```
269
+
270
+ Without Action Pack, the field fails (`Rate limit for Query.x exceeded. Try again later.`).
271
+ - **Instrumentation.** Each refused call is a `rate_limit.action_controller` event, with
272
+ `rate_limit`'s payload (`request`, `count`, `to`, `within`, `by`, `name`, `scope`,
273
+ `cache_key`).
274
+
275
+ A refused call never reaches `@guard` or the resolver.
@@ -38,18 +38,24 @@ end
38
38
 
39
39
  graphql-ruby delivers subscriptions through a *backend* that stores who subscribed
40
40
  and sends the updates: ActionCable (bundled), or a gem such as Pusher or Ably.
41
- Lighthouse needs none of its own; give the schema one. `get_schema` passes the
42
- `using:` option to graphql-ruby, as `use` does in a class-based schema:
41
+ Lighthouse needs none of its own; give the schema one with `use`, in the schema
42
+ class body, as in any graphql-ruby schema:
43
43
 
44
44
  ```ruby
45
45
  # app/graphql/app_schema.rb
46
- AppSchema = Lighthouse::GraphQL::RbLightHouse.get_schema(
47
- options: { using: { GraphQL::Subscriptions::ActionCableSubscriptions => {} } }
46
+ class AppSchema < Lighthouse::GraphQL::RbLightHouse.get_schema(
47
+ options: { base_types: { subscription: Subscriptions::BaseSubscription } } # and the other bases
48
48
  )
49
+ use GraphQL::Dataloader
50
+ use GraphQL::Subscriptions::ActionCableSubscriptions
51
+ end
49
52
  ```
50
53
 
51
- Options of the backend go in the hash (`{ ActionCableSubscriptions => { namespace: 'my-app' } }`).
52
- Assign the schema to a constant: a [queued](#queueing) broadcast finds it by name.
54
+ Options of the backend go after it (`use GraphQL::Subscriptions::ActionCableSubscriptions, namespace: 'my-app'`).
55
+ A schema assigned to a constant instead (`AppSchema = get_schema(...)`) takes the
56
+ backend through the `using:` option, as `use` does:
57
+ `options: { using: { GraphQL::Subscriptions::ActionCableSubscriptions => {} } }`.
58
+ Either way the schema has a name, which a [queued](#queueing) broadcast finds it by.
53
59
 
54
60
  Then add the channel graphql-ruby documents, which runs the operation and keeps
55
61
  the client's subscription ids ([the full example](https://graphql-ruby.org/subscriptions/action_cable_implementation.html)):
@@ -182,7 +188,7 @@ the base every subscription inherits from, as graphql-ruby's convention puts wha
182
188
  all classes of a kind share in their base:
183
189
 
184
190
  ```ruby
185
- # app/graphql/subscriptions/base_subscription.rb (rails generate lighthouse:install)
191
+ # app/graphql/subscriptions/base_subscription.rb (rails generate lighthouse:install --subscriptions)
186
192
  module Subscriptions
187
193
  class BaseSubscription < GraphQL::Schema::Subscription
188
194
  include Lighthouse::GraphQL::Subscription
@@ -411,8 +417,9 @@ end
411
417
 
412
418
  The job carries the schema's name, the subscription's name, the trigger arguments,
413
419
  the scope and the root; an Active Record model is serialized with GlobalID, any
414
- other root must be something ActiveJob can serialize. A schema built by
415
- `get_schema` and assigned to a constant (`AppSchema = ...`) has the name; an
420
+ other root must be something ActiveJob can serialize. A schema class
421
+ (`class AppSchema < ...get_schema(...)`), or a schema assigned to a constant
422
+ (`AppSchema = ...`), has the name; an
416
423
  anonymous one raises `Lighthouse::DefinitionError` when a queued broadcast needs
417
424
  it. The `activejob` gem is optional: nothing loads it unless a broadcast is queued.
418
425
 
data/docs/types.md CHANGED
@@ -69,17 +69,15 @@ Most schemas need nothing else.
69
69
 
70
70
  When the default is not enough, say when the values are Hashes from a search
71
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`:
72
+ (`rails g graphql:union SearchResult`): `Types::<Name>Type`, with `resolve_type`.
73
+ Its base, `Types::BaseUnion`, includes `Lighthouse::GraphQL::Type` after
74
+ `lighthouse:install` (see [opting in](#opting-in-and-base-classes)). It returns a
75
+ type name, or a type class, which stands for the SDL type of its `graphql_name`:
76
76
 
77
77
  ```ruby
78
78
  # app/graphql/types/search_result_type.rb
79
79
  module Types
80
80
  class SearchResultType < BaseUnion
81
- include Lighthouse::GraphQL::Type
82
-
83
81
  def self.resolve_type(object, context)
84
82
  return unless object.is_a?(Hash)
85
83
 
@@ -96,7 +94,6 @@ An interface's goes in graphql-ruby's `definition_methods`:
96
94
  module Types
97
95
  module AuthoredType
98
96
  include BaseInterface
99
- include Lighthouse::GraphQL::Type
100
97
 
101
98
  definition_methods do
102
99
  def resolve_type(object, context)
@@ -154,15 +151,14 @@ type Query {
154
151
  }
155
152
  ```
156
153
 
157
- Write the scalar's class with graphql-ruby's `rails g graphql:scalar DateTime`,
158
- `Types::<Name>Type` including `Lighthouse::GraphQL::Type`:
154
+ Write the scalar's class with graphql-ruby's `rails g graphql:scalar DateTime`:
155
+ `Types::<Name>Type`, a `Types::BaseScalar`, which includes `Lighthouse::GraphQL::Type`
156
+ after `lighthouse:install`:
159
157
 
160
158
  ```ruby
161
159
  # app/graphql/types/date_time_type.rb
162
160
  module Types
163
161
  class DateTimeType < BaseScalar
164
- include Lighthouse::GraphQL::Type
165
-
166
162
  def self.coerce_input(value, context)
167
163
  Time.iso8601(value.to_s)
168
164
  rescue ArgumentError
@@ -194,10 +190,12 @@ class with `coerce_input` and `coerce_result` fails when the schema is built.
194
190
  ## Opting in, and base classes
195
191
 
196
192
  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`.
193
+ `Types::SearchResultType` or `Types::DateTimeType` is left alone. `lighthouse:install`
194
+ includes the concern in graphql-ruby's `Types::BaseObject`, `BaseInterface`,
195
+ `BaseUnion` and `BaseScalar`, so every type class inheriting from them counts. In an
196
+ app that did not run it, include the concern by hand, in those bases or in each
197
+ class: a subclass of `GraphQL::Schema::Object`, `Union` or `Scalar`, or a module
198
+ including `GraphQL::Schema::Interface`.
201
199
 
202
200
  The classes are looked up in [`namespaces[:types]`](configuration.md#namespaces)
203
201
  (default `['Types']`). When the schema's `base_types` name a base for a kind,
@@ -205,15 +203,16 @@ graphql-ruby builds the SDL's types of that kind from it, and a type class of th
205
203
  kind must inherit from it, or the schema does not build:
206
204
 
207
205
  ```ruby
208
- AppSchema = Lighthouse::GraphQL::RbLightHouse.get_schema(
206
+ class AppSchema < Lighthouse::GraphQL::RbLightHouse.get_schema(
209
207
  options: {
210
208
  base_types: {
211
209
  object: Types::BaseObject,
212
210
  union: Types::BaseUnion,
213
211
  interface: Types::BaseInterface,
214
212
  scalar: Types::BaseScalar
215
- # resolver:, mutation:, subscription: (resolvers.md#handing-the-bases-to-the-schema)
213
+ # query_type:, node:, resolver:, mutation:, ... (resolvers.md#handing-the-bases-to-the-schema)
216
214
  }
217
215
  }
218
216
  )
217
+ end
219
218
  ```
data/docs/uploads.md CHANGED
@@ -27,7 +27,9 @@ on `User`, the mutation above attaches it.
27
27
 
28
28
  ## `@upload`
29
29
 
30
- `@upload` stores the file first, and passes along what was stored instead:
30
+ `@upload` stores the file first, and passes along what was stored instead, as PHP
31
+ Lighthouse's `@upload` does on a Laravel disk. The file goes to your app's
32
+ ActiveStorage, the way the rest of the app stores files:
31
33
 
32
34
  ```graphql
33
35
  type Mutation {
@@ -38,23 +40,42 @@ type Mutation {
38
40
  }
39
41
  ```
40
42
 
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.
43
+ | | PHP Lighthouse (Laravel) | lighthouse-graphql (ActiveStorage) |
44
+ | --- | --- | --- |
45
+ | `disk` | a disk of `config/filesystems.php`; `filesystems.default` without it | a service of `config/storage.yml`; `config.active_storage.service` without it |
46
+ | `path` (`"/"`) | the directory the file is stored in | the directory the blob's key starts with (`avatars/<token>`); at `"/"`, ActiveStorage's own key |
47
+ | `public` (`false`) | the file's visibility | must agree with the service's (see below) |
48
+ | the argument holds | the stored path | the blob's **signed id** |
49
+
50
+ The signed id is what an attachment attribute attaches (`user.avatar = signed_id`) and
51
+ `ActiveStorage::Blob.find_signed` finds; the blob keeps the file's name and content
52
+ type. Lists of files are stored one by one, and `null` stays `null`.
53
+
54
+ ActiveStorage makes a whole service public (`public: true` in `config/storage.yml`)
55
+ or private, never one file. So `public:` must agree with the service's, and the schema
56
+ never says a file is private that anyone can read: `@upload(public: true)` on a private
57
+ service fails, and so does `@upload` (`public: false`) on a public one. A service
58
+ `config/storage.yml` does not declare fails too.
59
+
60
+ To store files somewhere else (S3, GCS, Azure or a service of your own), declare an
61
+ ActiveStorage service for it and name it with `disk:`, as a Laravel app declares a disk:
62
+
63
+ ```yaml
64
+ # config/storage.yml
65
+ avatars:
66
+ service: S3
67
+ bucket: my-app-avatars
68
+ region: us-east-1
69
+ public: true
70
+ ```
71
+
72
+ ```graphql
73
+ updateAvatar(avatar: Upload! @upload(disk: "avatars", path: "users", public: true)): User!
74
+ ```
45
75
 
46
76
  `@upload` runs once `@guard`, the `@can*` family and `@validator` have let the call
47
77
  through, so a refused call stores nothing.
48
78
 
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
79
  ## The request
59
80
 
60
81
  ```shell
@@ -68,6 +89,12 @@ curl localhost:3000/graphql \
68
89
  A batch sends an array as `operations`, and maps files to `"0.variables.avatar"`,
69
90
  `"1.variables.files.0"`, and so on.
70
91
 
92
+ A multipart request is also what an HTML form on another site can send. If the GraphQL
93
+ controller authenticates with the session cookie, keep Rails' CSRF protection on it
94
+ (`protect_from_forgery`, on by default in `ActionController::Base`) and have clients send
95
+ the `X-CSRF-Token` header, as PHP Lighthouse has them send `X-Requested-With` for its
96
+ `EnsureXHR` middleware. A controller that authenticates with a token header is not exposed.
97
+
71
98
  In a request spec:
72
99
 
73
100
  ```ruby
@@ -4,19 +4,18 @@ require 'rails/generators'
4
4
  require 'lighthouse-graphql'
5
5
 
6
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.
7
+ # classes Lighthouse generates inherit from.
9
8
  #
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.
9
+ # graphql-ruby's `graphql:install` writes Resolvers::BaseResolver,
10
+ # Mutations::BaseMutation and every Types::Base*, and `lighthouse:install`
11
+ # builds on them: Lighthouse does not write copies of them. It writes only the
12
+ # base graphql-ruby has none of, Subscriptions::BaseSubscription, which
13
+ # includes Lighthouse's subscription concern.
15
14
  #
16
15
  # A base that already exists is never replaced, not even with --force: it holds
17
16
  # the app's own code.
18
17
  module Lighthouse::Generators::BaseClasses
19
- NAMES = { queries: 'BaseResolver', mutations: 'BaseMutation', subscriptions: 'BaseSubscription' }.freeze
18
+ NAMES = { subscriptions: 'BaseSubscription' }.freeze
20
19
 
21
20
  # The bases Lighthouse writes itself, graphql-ruby having none.
22
21
  SOURCES = {
@@ -39,19 +38,15 @@ module Lighthouse::Generators::BaseClasses
39
38
  NAMES[kind]
40
39
  end
41
40
 
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.
41
+ # Write the base class of `kind` into `namespace` when it is missing.
44
42
  def write_base_class(kind, namespace)
45
43
  return unless base_class_name(kind)
46
44
 
47
45
  path = base_class_path(kind, namespace)
48
46
  if File.exist?(File.join(destination_root, path))
49
47
  say_status :skip, "#{path} already exists", :yellow
50
- elsif SOURCES[kind]
51
- create_file path, ruby_source(namespace.split('::'), SOURCES[kind])
52
48
  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
49
+ create_file path, ruby_source(namespace.split('::'), SOURCES.fetch(kind))
55
50
  end
56
51
  end
57
52
 
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ # What graphql-ruby's `graphql:install` wrote in an app, as lighthouse:install
4
+ # reads it: where its schema class is (under app/, beside its types: --directory,
5
+ # --schema), which of its classes exist (--relay, --skip-mutation-root-type), and
6
+ # the parts of its templates Lighthouse takes out.
7
+ module Lighthouse::Generators::GraphqlRubyInstall
8
+ # graphql-ruby's code-first root types and Node interface, by base_types key (RootTypes).
9
+ ROOT_TYPES = { query_type: 'QueryType', mutation_type: 'MutationType', subscription_type: 'SubscriptionType', node: 'NodeType' }.freeze
10
+
11
+ # graphql-ruby's base types, by base_types key.
12
+ TYPE_BASES = {
13
+ object: 'BaseObject', interface: 'BaseInterface', union: 'BaseUnion', scalar: 'BaseScalar',
14
+ enum: 'BaseEnum', input_object: 'BaseInputObject'
15
+ }.freeze
16
+
17
+ # Where Lighthouse::GraphQL::Type goes in graphql-ruby's base types: after the
18
+ # class line, or the interface's own include.
19
+ TYPE_CONCERN_ANCHORS = {
20
+ 'base_object' => /^([ \t]*)class BaseObject < GraphQL::Schema::Object\n/,
21
+ 'base_union' => /^([ \t]*)class BaseUnion < GraphQL::Schema::Union\n/,
22
+ 'base_scalar' => /^([ \t]*)class BaseScalar < GraphQL::Schema::Scalar\n/,
23
+ 'base_interface' => /^([ \t]*)include GraphQL::Schema::Interface\n/
24
+ }.freeze
25
+
26
+ # The example field graphql-ruby's install puts in each root type.
27
+ TEST_FIELD = /\n+[ \t]*# TODO: remove me\n[ \t]*field :test_field,.*?\n[ \t]*def test_field\n.*?\n[ \t]*end\n/m
28
+
29
+ # The `resolve_type` stub graphql-ruby's install puts in the schema class.
30
+ RESOLVE_TYPE_STUB = /
31
+ \n+[ \t]*\#\ Union\ and\ Interface\ Resolution\n
32
+ [ \t]*def\ self\.resolve_type\(.*?\)\n
33
+ (?:[ \t]*\#.*\n)*
34
+ [ \t]*raise\(GraphQL::RequiredImplementationMissingError\)\n
35
+ [ \t]*end\n
36
+ /x
37
+
38
+ private
39
+
40
+ # The file of graphql-ruby's schema class under app/ (--schema picks one by name), or nil.
41
+ def schema_path
42
+ @schema_path ||= Dir[File.join(destination_root, 'app', '**', '*.rb')].filter_map do |file|
43
+ name = schema_class_name(File.read(file))
44
+ file.delete_prefix("#{destination_root}/") if name && (options[:schema].nil? || name.demodulize == options[:schema].camelize)
45
+ end.first
46
+ end
47
+
48
+ def schema_class_name(source)
49
+ source[/^[ \t]*class (\S+) < (?:GraphQL::Schema|Lighthouse::GraphQL::RbLightHouse\.get_schema)\b/, 1] ||
50
+ source[/^[ \t]*(\S+) = Lighthouse::GraphQL::RbLightHouse\.get_schema\b/, 1]
51
+ end
52
+
53
+ def schema_name
54
+ schema_class_name(read(schema_path)).demodulize
55
+ end
56
+
57
+ def lighthouse?(source)
58
+ source.include?('Lighthouse::GraphQL::RbLightHouse.get_schema')
59
+ end
60
+
61
+ # graphql-ruby's install: its defaults, an API app's smaller stack, the name --schema gives.
62
+ def graphql_install_options
63
+ { schema: options[:schema], api: api_only? || nil }.compact
64
+ end
65
+
66
+ def api_only?
67
+ exist?('config/application.rb') && read('config/application.rb').match?(/^\s*config\.api_only\s*=\s*true/)
68
+ end
69
+
70
+ # graphql-ruby writes its types beside the schema class (--directory).
71
+ def directory
72
+ File.dirname(schema_path)
73
+ end
74
+
75
+ def types_path(file)
76
+ File.join(directory, 'types', file)
77
+ end
78
+
79
+ # Lighthouse's base_types: every class graphql-ruby's install wrote, found
80
+ # where it writes it, as [key, constant] pairs.
81
+ def base_types
82
+ @base_types ||= type_classes(ROOT_TYPES) + resolver_classes + type_classes(TYPE_BASES)
83
+ end
84
+
85
+ def type_classes(names)
86
+ names.filter_map { |key, name| [key, "Types::#{name}"] if exist?(types_path("#{name.underscore}.rb")) }
87
+ end
88
+
89
+ def resolver_classes
90
+ classes = { resolver: %w[resolvers BaseResolver], mutation: %w[mutations BaseMutation] }
91
+ classes[:subscription] = %w[subscriptions BaseSubscription] if subscription_base?
92
+ classes.filter_map do |key, (folder, name)|
93
+ [key, "#{folder.camelize}::#{name}"] if exist?(File.join(directory, folder, "#{name.underscore}.rb"))
94
+ end
95
+ end
96
+
97
+ def subscription_base?
98
+ options[:subscriptions] || exist?(File.join(directory, 'subscriptions', 'base_subscription.rb'))
99
+ end
100
+
101
+ def graphql_ruby_controller?(source)
102
+ source.include?('def prepare_variables(variables_param)') && source.include?('def handle_error_in_development(e)') &&
103
+ source.include?("#{schema_name}.execute(query, variables: variables, context: context, operation_name: operation_name)")
104
+ end
105
+
106
+ def read(path)
107
+ File.read(File.join(destination_root, path))
108
+ end
109
+
110
+ def exist?(path)
111
+ File.exist?(File.join(destination_root, path))
112
+ end
113
+ end