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
@@ -1,23 +1,38 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'rails/generators'
4
+ require 'generators/graphql/install_generator'
4
5
  require 'lighthouse-graphql'
5
-
6
- # `rails generate lighthouse:install` — sets a Rails app up for Lighthouse:
7
- # the initializer, a starter SDL file, the schema class, a controller and
8
- # its route, plus the graphql-config file IDEs read. Rerunning it skips the
9
- # route, SDL and .gitignore entries that are already in place.
6
+ require_relative '../base_classes'
7
+
8
+ # `rails generate lighthouse:install` — sets a Rails app up for Lighthouse, on
9
+ # top of graphql-ruby's own install, which it runs first (`graphql:install
10
+ # --no-relay --skip-graphiql --skip-query-logs`, keeping the files the app has):
11
+ # graphql-ruby writes the base classes every graphql-ruby app has (Types::Base*,
12
+ # Resolvers::BaseResolver, Mutations::BaseMutation), its controller and route.
13
+ # Lighthouse then puts its own over what graphql-ruby wrote in this run:
14
+ #
15
+ # - the schema class, built from the SDL (graphql-ruby's is code-first);
16
+ # - the controller, with Lighthouse::GraphQL::Controller;
17
+ # - no Types::QueryType / MutationType: the SDL declares the root types;
18
+ # - Mutations::BaseMutation a GraphQL::Schema::Mutation, as the SDL declares a
19
+ # mutation's arguments (graphql-ruby's is a RelayClassicMutation, one `input:`);
10
20
  #
11
- # It coexists with graphql-ruby's `graphql:install`, which writes the same
12
- # controller path and route: an existing schema class or controller is kept
13
- # (pass --force to replace it) instead of overwritten, so an app already
14
- # serving a graphql-ruby schema keeps doing so until it is pointed at ours.
21
+ # and adds the initializer, a starter SDL file, Subscriptions::BaseSubscription
22
+ # and the graphql-config file IDEs read. Rerunning it skips what is in place. A
23
+ # schema class or controller the app had before is kept (pass --force to replace
24
+ # it), so an app already serving a graphql-ruby schema keeps doing so until it
25
+ # is pointed at ours.
15
26
  class Lighthouse::Generators::InstallGenerator < Rails::Generators::Base
27
+ include Lighthouse::Generators::BaseClasses
28
+
16
29
  CONTROLLER = 'app/controllers/graphql_controller.rb'
30
+ ROOT_TYPES = %w[app/graphql/types/query_type.rb app/graphql/types/mutation_type.rb].freeze
31
+ BASE_MUTATION = 'app/graphql/mutations/base_mutation.rb'
17
32
 
18
33
  source_root File.expand_path('templates', __dir__)
19
34
 
20
- desc 'Install Lighthouse: initializer, starter schema, schema class, controller, route and IDE config.'
35
+ desc 'Install Lighthouse: initializer, starter schema, schema class, base classes, controller, route and IDE config.'
21
36
 
22
37
  class_option :schema, type: :string, default: 'AppSchema',
23
38
  desc: 'Name of the schema constant to generate'
@@ -28,6 +43,13 @@ class Lighthouse::Generators::InstallGenerator < Rails::Generators::Base
28
43
  class_option :skip_ide, type: :boolean, default: false,
29
44
  desc: 'Skip graphql.config.yml and the .gitignore entries for the IDE helper files'
30
45
 
46
+ # graphql-ruby's install, for the base classes. What it writes that Lighthouse
47
+ # replaces is noted, so a file the app had before is never touched.
48
+ def install_graphql_ruby
49
+ @before = ([schema_path, CONTROLLER, BASE_MUTATION] + ROOT_TYPES).select { |path| exist?(path) }
50
+ invoke 'graphql:install', [], schema: schema_name, relay: false, skip_graphiql: true, skip_query_logs: true, skip: true
51
+ end
52
+
31
53
  def create_initializer
32
54
  template 'initializer.rb.tt', 'config/initializers/lighthouse.rb'
33
55
  end
@@ -42,17 +64,39 @@ class Lighthouse::Generators::InstallGenerator < Rails::Generators::Base
42
64
  end
43
65
 
44
66
  def create_schema_class
45
- path = File.join('app/graphql', "#{schema_name.underscore}.rb")
46
- return if keep_existing?(path)
67
+ return if keep_existing?(schema_path)
68
+
69
+ template 'schema.rb.tt', schema_path, force: true
70
+ end
71
+
72
+ # The SDL declares the root types: graphql-ruby's code-first ones go. Not when
73
+ # the app keeps a code-first schema of its own, which graphql-ruby's install
74
+ # pointed at them.
75
+ def remove_root_types
76
+ return unless exist?(schema_path) && File.read(File.join(destination_root, schema_path)).include?('RbLightHouse.get_schema')
77
+
78
+ ROOT_TYPES.each { |path| remove_file path unless before?(path) }
79
+ end
80
+
81
+ # The SDL declares a mutation's arguments, so its class is a plain
82
+ # GraphQL::Schema::Mutation, not graphql-ruby's RelayClassicMutation.
83
+ def adapt_base_mutation
84
+ return if before?(BASE_MUTATION) || !exist?(BASE_MUTATION)
85
+
86
+ gsub_file BASE_MUTATION, 'GraphQL::Schema::RelayClassicMutation', 'GraphQL::Schema::Mutation'
87
+ gsub_file BASE_MUTATION, /^\s*input_object_class .*\n/, ''
88
+ end
47
89
 
48
- template 'schema.rb.tt', path
90
+ # Subscriptions::BaseSubscription, which graphql-ruby has none of (see BaseClasses).
91
+ def create_base_subscription
92
+ write_base_class(:subscriptions, Lighthouse::Config.namespaces[:subscriptions].first)
49
93
  end
50
94
 
51
95
  def create_controller
52
- return if options[:skip_controller]
96
+ return remove_graphql_ruby_controller if options[:skip_controller]
53
97
  return if (@kept_controller = keep_existing?(CONTROLLER))
54
98
 
55
- template 'graphql_controller.rb.tt', CONTROLLER
99
+ template 'graphql_controller.rb.tt', CONTROLLER, force: true
56
100
  end
57
101
 
58
102
  def add_route
@@ -93,26 +137,58 @@ class Lighthouse::Generators::InstallGenerator < Rails::Generators::Base
93
137
 
94
138
  Lighthouse is installed. Next steps:
95
139
  1. Describe your API in #{sdl_folder}/*.graphql (the starter schema assumes a User model).
96
- 2. Generate the IDE helper files: bin/rails lighthouse:ide_helper
97
- 3. #{serve}
140
+ 2. Resolve a field in Ruby when a directive is not enough: bin/rails g lighthouse:query LatestPosts
141
+ (it inherits from Resolvers::BaseResolver, which graphql-ruby's install wrote; lighthouse:mutation and
142
+ lighthouse:subscription write the others)
143
+ 3. Give a type Ruby methods, a union or interface its resolve_type, a scalar its coercion:
144
+ bin/rails g graphql:object Post (or graphql:union, graphql:interface, graphql:scalar),
145
+ then include Lighthouse::GraphQL::Type in the class
146
+ 4. Generate the IDE helper files: bin/rails lighthouse:ide_helper
147
+ 5. #{serve}
98
148
  MSG
99
149
  end
100
150
 
101
151
  private
102
152
 
103
- # Files another generator may own (graphql-ruby's install writes the same
104
- # controller) are left alone unless --force is given.
153
+ # A file the app had before this run is left alone unless --force is given;
154
+ # one graphql-ruby's install wrote in this run is Lighthouse's to replace.
105
155
  def keep_existing?(path)
106
- return false if options[:force] || !File.exist?(File.join(destination_root, path))
156
+ return false if options[:force] || !before?(path)
107
157
 
108
158
  say_status :skip, "#{path} already exists (pass --force to replace it)", :yellow
109
159
  true
110
160
  end
111
161
 
162
+ def before?(path)
163
+ @before.include?(path)
164
+ end
165
+
166
+ def exist?(path)
167
+ File.exist?(File.join(destination_root, path))
168
+ end
169
+
170
+ def schema_path
171
+ File.join('app/graphql', "#{schema_name.underscore}.rb")
172
+ end
173
+
174
+ # --skip-controller: the app serves the schema itself, so graphql-ruby's
175
+ # controller and route, if its install just wrote them, go.
176
+ def remove_graphql_ruby_controller
177
+ return if before?(CONTROLLER)
178
+
179
+ remove_file CONTROLLER
180
+ gsub_file 'config/routes.rb', %r{^\s*post "/graphql", to: "graphql#execute"\n}, '' if exist?('config/routes.rb')
181
+ end
182
+
112
183
  def schema_name
113
184
  options[:schema].camelize
114
185
  end
115
186
 
187
+ # `Resolvers::BaseResolver`, for the schema class template.
188
+ def base_class_constant(kind)
189
+ "#{Lighthouse::Config.namespaces[kind].first}::#{base_class_name(kind)}"
190
+ end
191
+
116
192
  def sdl_folder
117
193
  options[:sdl_folder].delete_suffix('/')
118
194
  end
@@ -12,25 +12,34 @@ Lighthouse.configure do |config|
12
12
 
13
13
  # Where classes named by the schema are looked up, by kind. A Query or Mutation
14
14
  # field without a resolving directive resolves by convention: Query.latestPosts
15
- # to Queries::LatestPosts (a GraphQL::Schema::Resolver), Mutation.publishPost
16
- # to Mutations::PublishPost (a GraphQL::Schema::Mutation). @builder classes
15
+ # to Resolvers::LatestPosts (a Resolvers::BaseResolver), Mutation.publishPost
16
+ # to Mutations::PublishPost (a Mutations::BaseMutation). @builder classes
17
17
  # come from :builders and @validator contracts from :validators. A Subscription
18
- # field resolves to Subscriptions::PostUpdated (a GraphQL::Schema::Subscription),
19
- # and @event(dispatch:) classes come from :events. Generate them with
20
- # `rails g lighthouse:query|mutation|subscription|builder|validator Name`.
18
+ # field resolves to Subscriptions::PostUpdated (a Subscriptions::BaseSubscription),
19
+ # @event(dispatch:) classes come from :events, and the graphql-ruby analyzers
20
+ # @complexity(resolver:) names from :analyzers. Post.readingTime, with no
21
+ # resolving directive, resolves with Types::PostType#reading_time (a
22
+ # Types::BaseObject including Lighthouse::GraphQL::Type), from :types. Generate them with
23
+ # `rails g lighthouse:query|mutation|subscription|type|builder|validator Name`.
21
24
  # config.namespaces = {
22
- # queries: ['Queries'], mutations: ['Mutations'], subscriptions: ['Subscriptions'],
23
- # builders: ['Builders'], validators: ['Validators'], events: ['Events']
25
+ # queries: ['Resolvers'], mutations: ['Mutations'], subscriptions: ['Subscriptions'],
26
+ # builders: ['Builders'], validators: ['Validators'], events: ['Events'], analyzers: ['Analyzers'],
27
+ # types: ['Types']
24
28
  # }
25
29
 
26
30
  # Namespaces tried, in order, for models (@all(model: "User")) and for
27
- # @field(resolver:) on types other than Query and Mutation. Fully-qualified
28
- # names (containing `::`) are used as-is.
31
+ # @field(resolver:) on types other than Query and Mutation, after :queries
32
+ # (@field(resolver: "Posts::TagsCount") is Resolvers::Posts::TagsCount).
33
+ # Fully-qualified names (containing `::`) are used as-is.
29
34
  # config.resolver_namespaces = ['App::Graphql']
30
35
 
31
- # Store for @cache and @clearCache.
36
+ # Store for @cache, @clearCache and @throttle's counts.
32
37
  # config.cache_store = Rails.cache
33
38
 
39
+ # Where @upload stores a file: call(file, disk:, path:, public:) returns what the
40
+ # argument holds. By default an ActiveStorage blob, and its signed id.
41
+ # config.upload_store = Lighthouse::GraphQL::UploadStore
42
+
34
43
  # Run @create/@update/@createMany/@updateMany in a database transaction.
35
44
  # config.transactional_mutations = true
36
45
 
@@ -2,9 +2,28 @@
2
2
 
3
3
  # Executable schema built by Lighthouse from the SDL in <%= sdl_folder %>/. The
4
4
  # Dataloader batches relationship loads (@hasMany, @belongsTo, ...) to avoid N+1.
5
+ # base_types names the base classes: every class a Query, Mutation or Subscription
6
+ # field resolves to must inherit from its kind's, and a Subscription field with no
7
+ # class of its own resolves with the subscription one. graphql-ruby builds the
8
+ # SDL's types from the others (written by `rails g graphql:install`), and type
9
+ # classes (Types::PostType, Types::DateTimeType, ...) inherit from them.
5
10
  <%= schema_name %> = Lighthouse::GraphQL::RbLightHouse.get_schema(
6
11
  options: {
7
- using: { GraphQL::Dataloader => {} }
8
- # base_types: { object: Types::BaseObject } # your graphql-ruby base classes
12
+ using: { GraphQL::Dataloader => {} },
13
+ base_types: {
14
+ resolver: <%= base_class_constant(:queries) %>,
15
+ mutation: <%= base_class_constant(:mutations) %>,
16
+ subscription: <%= base_class_constant(:subscriptions) %>,
17
+ object: Types::BaseObject,
18
+ interface: Types::BaseInterface,
19
+ union: Types::BaseUnion,
20
+ scalar: Types::BaseScalar,
21
+ enum: Types::BaseEnum,
22
+ input_object: Types::BaseInputObject
23
+ }
9
24
  }
10
25
  )
26
+
27
+ # Refuse expensive queries before they run (see the gem's docs/security.md):
28
+ # <%= schema_name %>.max_complexity(300)
29
+ # <%= schema_name %>.max_depth(12)
@@ -2,10 +2,11 @@
2
2
 
3
3
  require_relative 'class_generator'
4
4
 
5
- # `rails generate lighthouse:mutation PublishPost` — a GraphQL::Schema::Mutation
6
- # that `Mutation.publishPost` resolves to by convention.
5
+ # `rails generate lighthouse:mutation PublishPost` — the class (a
6
+ # Mutations::BaseMutation, so a GraphQL::Schema::Mutation) that `Mutation.publishPost`
7
+ # resolves to by convention.
7
8
  class Lighthouse::Generators::MutationGenerator < Lighthouse::Generators::ClassGenerator
8
- desc 'Generate a mutation class (GraphQL::Schema::Mutation) in app/graphql/mutations.'
9
+ desc 'Generate the class of a Mutation field (a Mutations::BaseMutation) in app/graphql/mutations.'
9
10
 
10
11
  private
11
12
 
@@ -16,7 +17,7 @@ class Lighthouse::Generators::MutationGenerator < Lighthouse::Generators::ClassG
16
17
  # Resolves Mutation.#{class_basename.camelize(:lower)}. Keyword arguments are the field's
17
18
  # arguments in snake_case; `object` and `context` are available. Raise
18
19
  # GraphQL::ExecutionError for failures the client should see.
19
- class #{class_basename} < GraphQL::Schema::Mutation
20
+ class #{class_basename} < #{superclass_name}
20
21
  def resolve(**args)
21
22
  raise NotImplementedError
22
23
  end
@@ -2,10 +2,11 @@
2
2
 
3
3
  require_relative 'class_generator'
4
4
 
5
- # `rails generate lighthouse:query LatestPosts` — a GraphQL::Schema::Resolver
6
- # that `Query.latestPosts` resolves to by convention.
5
+ # `rails generate lighthouse:query LatestPosts` — the resolver (a
6
+ # Resolvers::BaseResolver, so a GraphQL::Schema::Resolver) that `Query.latestPosts`
7
+ # resolves to by convention.
7
8
  class Lighthouse::Generators::QueryGenerator < Lighthouse::Generators::ClassGenerator
8
- desc 'Generate a query class (GraphQL::Schema::Resolver) in app/graphql/queries.'
9
+ desc 'Generate the resolver of a Query field (a Resolvers::BaseResolver) in app/graphql/resolvers.'
9
10
 
10
11
  private
11
12
 
@@ -15,7 +16,7 @@ class Lighthouse::Generators::QueryGenerator < Lighthouse::Generators::ClassGene
15
16
  <<~RUBY
16
17
  # Resolves Query.#{class_basename.camelize(:lower)}. Keyword arguments are the field's
17
18
  # arguments in snake_case; `object` and `context` are available.
18
- class #{class_basename} < GraphQL::Schema::Resolver
19
+ class #{class_basename} < #{superclass_name}
19
20
  def resolve(**args)
20
21
  raise NotImplementedError
21
22
  end
@@ -2,22 +2,27 @@
2
2
 
3
3
  require_relative 'class_generator'
4
4
 
5
- # `rails generate lighthouse:subscription PostUpdated` — a
6
- # GraphQL::Schema::Subscription that `Subscription.postUpdated` resolves to by
7
- # convention. With `--lighthouse`, the class also includes
8
- # Lighthouse::GraphQL::Subscription (PHP Lighthouse's authorize?/filter?/resolve_payload hooks).
5
+ # `rails generate lighthouse:subscription PostUpdated` — the class (a
6
+ # Subscriptions::BaseSubscription, so a GraphQL::Schema::Subscription) that
7
+ # `Subscription.postUpdated` resolves to by convention. The base Lighthouse writes
8
+ # includes Lighthouse::GraphQL::Subscription, so the class gets PHP Lighthouse's
9
+ # authorize?/filter?/resolve_payload hooks; under a base without the concern, it
10
+ # gets graphql-ruby's authorized?/update instead.
9
11
  class Lighthouse::Generators::SubscriptionGenerator < Lighthouse::Generators::ClassGenerator
10
- desc 'Generate a subscription class (GraphQL::Schema::Subscription) in app/graphql/subscriptions.'
11
-
12
- class_option :lighthouse, type: :boolean, default: false,
13
- desc: "Include Lighthouse::GraphQL::Subscription: PHP Lighthouse's authorize?/filter?/resolve_payload hooks"
12
+ desc 'Generate the class of a Subscription field (a Subscriptions::BaseSubscription) in app/graphql/subscriptions.'
14
13
 
15
14
  private
16
15
 
17
16
  def kind = :subscriptions
18
17
 
19
18
  def body
20
- options[:lighthouse] ? lighthouse_body : plain_body
19
+ lighthouse_base? ? lighthouse_body : plain_body
20
+ end
21
+
22
+ # Does the namespace's base class include Lighthouse's concern?
23
+ def lighthouse_base?
24
+ path = File.join(destination_root, base_class_path(kind, namespace_name))
25
+ File.exist?(path) && File.read(path).include?('Lighthouse::GraphQL::Subscription')
21
26
  end
22
27
 
23
28
  def plain_body
@@ -25,7 +30,7 @@ class Lighthouse::Generators::SubscriptionGenerator < Lighthouse::Generators::Cl
25
30
  # Resolves Subscription.#{field_name}. Keyword arguments are the field's arguments in
26
31
  # snake_case; `object` and `context` are available. `context` is the subscriber's:
27
32
  # graphql-ruby stores it with the subscription and replays it for every update.
28
- class #{class_basename} < GraphQL::Schema::Subscription
33
+ class #{class_basename} < #{superclass_name}
29
34
  # Runs for the subscription request and again for every update. Raise a
30
35
  # GraphQL::ExecutionError to refuse with a message (false refuses silently).
31
36
  def authorized?(**args)
@@ -62,12 +67,12 @@ class Lighthouse::Generators::SubscriptionGenerator < Lighthouse::Generators::Cl
62
67
 
63
68
  def lighthouse_body
64
69
  <<~RUBY
65
- # Resolves Subscription.#{field_name}. A graphql-ruby subscription with PHP Lighthouse's
66
- # hooks: every subscriber of the field gets every broadcast, and `filter?` decides.
67
- # All hooks are optional; `object` and `context` (the subscriber's) are available.
68
- class #{class_basename} < GraphQL::Schema::Subscription
69
- include Lighthouse::GraphQL::Subscription
70
-
70
+ # Resolves Subscription.#{field_name}. #{superclass_name} includes Lighthouse::GraphQL::Subscription,
71
+ # PHP Lighthouse's hooks: every subscriber of the field gets every broadcast, and
72
+ # `filter?` decides. All hooks are optional; `object` and `context` (the
73
+ # subscriber's) are available. graphql-ruby's own `authorized?` or `update`,
74
+ # defined here, win over them.
75
+ class #{class_basename} < #{superclass_name}
71
76
  # May this client subscribe? Keyword arguments are the field's arguments.
72
77
  def authorize?(**args)
73
78
  true
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_job'
4
+
5
+ # Runs what resolves an @async mutation field, from a queue. Loaded on first
6
+ # use — the activejob gem is optional — so it is autoloaded, as BroadcastJob.
7
+ # The schema is passed by name; the field's resolver runs with the arguments
8
+ # and context values the request serialized, inside the schema's Dataloader.
9
+ class Lighthouse::GraphQL::AsyncMutationJob < ActiveJob::Base # rubocop:disable Rails/ApplicationJob
10
+ def perform(schema_name, type_name, field_name, arguments, context_values)
11
+ schema = schema_name.constantize
12
+ type = schema.get_type(type_name)
13
+ inner = Lighthouse::GraphQL::DirectiveResolvers::AsyncDirectiveResolver.inner(type, field_name)
14
+ context = ::GraphQL::Query.new(schema, '{ __typename }', context: context_values.merge(lighthouse_async: true)).context
15
+ context.dataloader.run_isolated { inner.call(nil, arguments.transform_keys(&:to_sym), context) }
16
+ end
17
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ # @complexity — what a field costs in a query's complexity score, the score
4
+ # graphql-ruby checks against the schema's `max_complexity` before it runs a
5
+ # query (GraphQL::Analysis::MaxQueryComplexity), as PHP Lighthouse does with
6
+ # webonyx's QueryComplexity rule.
7
+ #
8
+ # Lighthouse scores nothing itself: it gives fields their cost through
9
+ # graphql-ruby's `Field#complexity_for` hook, and graphql-ruby's analyzers add
10
+ # the costs up. A field costs:
11
+ #
12
+ # - With a bare @complexity, and on every paginated field (@paginate, and the
13
+ # relationship directives given `type:`), 1 plus its selection's cost once per
14
+ # item asked for (`first`, which defaults to the page size).
15
+ # - With @complexity(resolver: "Name"), the `complexity` the analyzer class
16
+ # declares when it includes Lighthouse::GraphQL::ComplexityAnalyzer; else as
17
+ # with a bare @complexity.
18
+ # - When a GraphQL::Schema::Resolver class resolves it (by convention, as
19
+ # `Resolvers::Search` resolves `Query.search`, or through @field), the
20
+ # `complexity` that class declares, as graphql-ruby gives `field ..., resolver:`.
21
+ # - Otherwise graphql-ruby's default: 1 plus its selection's cost.
22
+ #
23
+ # `resolver:` names a GraphQL::Analysis::Analyzer subclass, looked up in
24
+ # Config.namespaces[:analyzers] (`Analyzers::Name`). Lighthouse installs it on
25
+ # the schema, as graphql-ruby's `query_analyzer Analyzers::Name` would, so it
26
+ # runs on every query.
27
+ #
28
+ # Analyzer scores each query for BuildExtensionsResponse#query_complexity while
29
+ # something listens to that event.
30
+ module Lighthouse::GraphQL::Complexity
31
+ DEFINITION = 'directive @complexity(resolver: String) on FIELD_DEFINITION'
32
+
33
+ CONTEXT_KEY = :lighthouse_query_complexity
34
+
35
+ module_function
36
+
37
+ # Give the schema's fields their cost and install the analyzers @complexity
38
+ # names (see above). `root_kinds` maps the root types' names to :queries /
39
+ # :mutations / :subscriptions.
40
+ def apply(schema, root_kinds = {})
41
+ schema.types.each_value do |type|
42
+ next unless type.kind.object? || type.kind.interface?
43
+
44
+ type.fields.each_value do |field|
45
+ calculator = calculator_for(schema, type, field, root_kinds[type.graphql_name])
46
+ install(field, calculator) if calculator
47
+ end
48
+ end
49
+ schema
50
+ end
51
+
52
+ # A query's score, once Analyzer has run on it; nil otherwise.
53
+ def query_complexity(query)
54
+ query.context[CONTEXT_KEY]
55
+ end
56
+
57
+ # graphql-ruby asks the field for its cost through `complexity_for`, which wins
58
+ # over a number or proc in `complexity` and over its own scaling of connections.
59
+ def install(field, calculator)
60
+ field.define_singleton_method(:complexity_for) do |child_complexity:, query:, lookahead:|
61
+ calculator.call(query.context, lookahead.arguments, child_complexity)
62
+ end
63
+ end
64
+
65
+ # A callable (context, args, child_complexity) -> cost, or nil to leave the
66
+ # field to graphql-ruby.
67
+ def calculator_for(schema, type, field, kind)
68
+ return unless field.ast_node
69
+
70
+ directives = field.ast_node.directives || []
71
+ directive = directives.find { |d| d.name == 'complexity' }
72
+ return directive_calculator(schema, directive, "#{type.graphql_name}.#{field.name}") if directive
73
+ return method(:per_item) if paginated?(directives)
74
+
75
+ resolver_class_calculator(field, directives, kind)
76
+ end
77
+
78
+ def directive_calculator(schema, directive, path)
79
+ name = Lighthouse::Support::DirectiveArgs.literal(directive, 'resolver')
80
+ return method(:per_item) if name.nil? || name.to_s.strip.empty?
81
+
82
+ analyzer = analyzer_class(name.to_s, path)
83
+ schema.query_analyzer(analyzer) unless schema.query_analyzers.include?(analyzer)
84
+ return method(:per_item) unless analyzer.include?(Lighthouse::GraphQL::ComplexityAnalyzer)
85
+
86
+ lambda do |context, args, child_complexity|
87
+ cost = analyzer.complexity
88
+ cost.nil? ? per_item(context, args, child_complexity) : own_cost(cost, context, args, child_complexity)
89
+ end
90
+ end
91
+
92
+ def analyzer_class(name, path)
93
+ namespaces = Lighthouse::Config.namespaces[:analyzers]
94
+ klass = Lighthouse::Support::ModelResolver.constantize(name, namespaces: namespaces)
95
+ unless klass
96
+ raise Lighthouse::DefinitionError,
97
+ "@complexity on #{path}: no analyzer #{name} (looked up in #{Array(namespaces).join(', ')})"
98
+ end
99
+ return klass if klass.is_a?(Class) && klass < ::GraphQL::Analysis::Analyzer
100
+
101
+ raise Lighthouse::DefinitionError, "@complexity on #{path}: #{klass} must inherit from GraphQL::Analysis::Analyzer"
102
+ end
103
+
104
+ # The field and its selection once per item asked for: PHP Lighthouse's
105
+ # default, and what a page of `first` items costs.
106
+ def per_item(_context, args, child_complexity)
107
+ 1 + (child_complexity * (args[:first] || 1))
108
+ end
109
+
110
+ # graphql-ruby's default cost: the field, plus its selection.
111
+ def default_cost(child_complexity)
112
+ 1 + child_complexity
113
+ end
114
+
115
+ # A cost as graphql-ruby's Field#complexity takes it: a number, added to the
116
+ # selection's, or a proc (context, args, child_complexity).
117
+ def own_cost(cost, context, args, child_complexity)
118
+ cost.respond_to?(:call) ? cost.call(context, args, child_complexity) : cost + child_complexity
119
+ end
120
+
121
+ def paginated?(directives)
122
+ relations = Lighthouse::GraphQL::Directives::PaginationManipulator::RELATION_DIRECTIVES
123
+ directives.any? do |d|
124
+ d.name == 'paginate' || (relations.include?(d.name) && Lighthouse::Support::DirectiveArgs.literal(d, 'type'))
125
+ end
126
+ end
127
+
128
+ # The `complexity` of the GraphQL::Schema::Resolver class that resolves the
129
+ # field, by convention or through @field; nil when no such class can. The
130
+ # class is looked up when a query is scored, so code reloading works.
131
+ def resolver_class_calculator(field, directives, kind)
132
+ lookup = resolver_class_lookup(field, directives, kind)
133
+ return unless lookup
134
+
135
+ lambda do |context, args, child_complexity|
136
+ klass = lookup.call
137
+ next default_cost(child_complexity) unless Lighthouse::GraphQL::ResolverDispatch.graphql_resolver?(klass)
138
+
139
+ own_cost(klass.complexity, context, args, child_complexity)
140
+ end
141
+ end
142
+
143
+ def resolver_class_lookup(field, directives, kind)
144
+ directive = directives.find { |d| d.name == 'field' }
145
+ return field_class_lookup(directive, kind) if directive
146
+ return unless %i[queries mutations].include?(kind) && !Lighthouse::GraphQL::FieldResolverStack.resolving_directive?(directives)
147
+
148
+ class_name = Lighthouse::GraphQL::DirectiveResolvers::ConventionResolver.class_name_for(field)
149
+ -> { Lighthouse::Support::ModelResolver.constantize(class_name, namespaces: Lighthouse::Config.namespaces[kind], fallback: false) }
150
+ end
151
+
152
+ # The class @field names, looked up as @field looks it up.
153
+ def field_class_lookup(directive, kind)
154
+ reference = %w[resolver class].filter_map { |name| Lighthouse::Support::DirectiveArgs.literal(directive, name) }.first
155
+ return unless reference
156
+
157
+ class_name, = Lighthouse::GraphQL::DirectiveResolvers::FieldDirectiveResolver.reference(reference)
158
+
159
+ -> { Lighthouse::GraphQL::DirectiveResolvers::FieldDirectiveResolver.lookup(class_name, kind) }
160
+ end
161
+
162
+ # Scores each query as graphql-ruby's MaxQueryComplexity scores it against
163
+ # `max_complexity`, for BuildExtensionsResponse#query_complexity. Runs only
164
+ # while something listens to that event.
165
+ class Analyzer < ::GraphQL::Analysis::QueryComplexity
166
+ def analyze?
167
+ Lighthouse::Events.listening?(Lighthouse::Events::BuildExtensionsResponse)
168
+ end
169
+
170
+ def result
171
+ query.context[CONTEXT_KEY] = max_possible_complexity
172
+ nil
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+
5
+ # A concern for a graphql-ruby analyzer that @complexity(resolver:) names. Its
6
+ # one method, `complexity`, gives the fields naming the class their cost, as
7
+ # graphql-ruby's `complexity` gives a field or a GraphQL::Schema::Resolver one:
8
+ #
9
+ # class Analyzers::TagPostsComplexity < GraphQL::Analysis::QueryComplexity
10
+ # include Lighthouse::GraphQL::ComplexityAnalyzer
11
+ #
12
+ # complexity ->(_context, _args, child_complexity) { 1 + (child_complexity * 25) }
13
+ # end
14
+ #
15
+ # A number is added to the cost of the field's selection; a proc is called with
16
+ # (context, args, child_complexity) and returns the whole cost. Without
17
+ # `complexity`, the field costs what a bare @complexity gives.
18
+ #
19
+ # The class stays an analyzer, installed on the schema like any class @complexity
20
+ # names: a `result` of its own runs on every query, in graphql-ruby's analysis.
21
+ # Without one it only gives costs, and graphql-ruby skips it.
22
+ module Lighthouse::GraphQL::ComplexityAnalyzer
23
+ extend ActiveSupport::Concern
24
+
25
+ class_methods do
26
+ # Set the cost (a number or a proc taking context, args and child_complexity),
27
+ # or read it: the class's own, else its superclass's, else nil.
28
+ def complexity(new_complexity = nil)
29
+ return inherited_complexity if new_complexity.nil?
30
+
31
+ unless new_complexity.is_a?(Numeric) || (new_complexity.is_a?(Proc) && new_complexity.parameters.size == 3)
32
+ raise ArgumentError, "#{name}.complexity takes a number or a proc of (context, args, child_complexity), " \
33
+ "got #{new_complexity.inspect}"
34
+ end
35
+
36
+ @complexity = new_complexity
37
+ end
38
+
39
+ private
40
+
41
+ def inherited_complexity
42
+ return @complexity if defined?(@complexity)
43
+
44
+ superclass.respond_to?(:complexity) ? superclass.complexity : nil
45
+ end
46
+ end
47
+
48
+ # Run in graphql-ruby's analysis only when the class has a `result` of its own.
49
+ def analyze?
50
+ self.class.instance_method(:result).owner != Lighthouse::GraphQL::ComplexityAnalyzer && super
51
+ end
52
+
53
+ def result
54
+ nil
55
+ end
56
+ end