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
@@ -4,50 +4,66 @@ require 'rails/generators'
4
4
  require 'generators/graphql/install_generator'
5
5
  require 'lighthouse-graphql'
6
6
  require_relative '../base_classes'
7
+ require_relative 'graphql_ruby_install'
7
8
 
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:
9
+ # `rails generate lighthouse:install` — puts Lighthouse on top of graphql-ruby's
10
+ # own install (`rails g graphql:install`, with whichever of its options the app
11
+ # chose: --relay, --batch, --skip-mutation-root-type, --schema, --directory,
12
+ # ...). When the app has none, it runs graphql:install first, with its
13
+ # defaults. It then adapts what graphql-ruby wrote, instead of writing its own:
14
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:`);
15
+ # - the schema class (`class AppSchema < GraphQL::Schema`) becomes Lighthouse's,
16
+ # built from the SDL on everything graphql-ruby wrote: its base types, base
17
+ # resolver and base mutation, and its code-first Types::QueryType /
18
+ # MutationType, which the SDL's root types extend, so their `field`s (Relay's
19
+ # `node` / `nodes`, what `graphql:mutation` adds) stay. Its Relay
20
+ # Types::NodeType is the SDL's Node. Everything else in the class is
21
+ # graphql-ruby's and stays, but the `resolve_type` stub (Lighthouse resolves
22
+ # types) and the root types it is built on;
23
+ # - the example `test_field` goes from the root types;
24
+ # - graphql-ruby's base object, interface, union and scalar include
25
+ # Lighthouse::GraphQL::Type, so every type class (Types::QueryType,
26
+ # Types::NodeType, what `graphql:object` writes) is Lighthouse's;
27
+ # - with --batch, the schema uses GraphQL::Dataloader too, which Lighthouse's
28
+ # directives batch through;
29
+ # - graphql-ruby's controller, as its install wrote it, becomes one with
30
+ # Lighthouse::GraphQL::Controller.
20
31
  #
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.
32
+ # It adds the initializer, a starter SDL file and the graphql-config file IDEs
33
+ # read, and with --subscriptions, Subscriptions::BaseSubscription. Rerunning it
34
+ # skips what is in place.
26
35
  class Lighthouse::Generators::InstallGenerator < Rails::Generators::Base
27
36
  include Lighthouse::Generators::BaseClasses
37
+ include Lighthouse::Generators::GraphqlRubyInstall
28
38
 
29
39
  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'
32
40
 
33
41
  source_root File.expand_path('templates', __dir__)
34
42
 
35
- desc 'Install Lighthouse: initializer, starter schema, schema class, base classes, controller, route and IDE config.'
43
+ desc "Install Lighthouse on graphql-ruby's install: schema class, base classes, controller, initializer, starter SDL and IDE config."
36
44
 
37
- class_option :schema, type: :string, default: 'AppSchema',
38
- desc: 'Name of the schema constant to generate'
45
+ class_option :schema, type: :string,
46
+ desc: "The schema class to adopt; for graphql:install, when it runs, its name (default: graphql-ruby's)"
39
47
  class_option :sdl_folder, type: :string, default: 'graphql',
40
48
  desc: 'Folder, relative to the app root, holding the *.graphql files'
49
+ class_option :subscriptions, type: :boolean, default: false,
50
+ desc: 'Add Subscriptions::BaseSubscription, the base of the classes Subscription fields resolve to'
41
51
  class_option :skip_controller, type: :boolean, default: false,
42
- desc: 'Skip the GraphqlController and its route'
52
+ desc: "Leave graphql-ruby's GraphqlController as it is"
43
53
  class_option :skip_ide, type: :boolean, default: false,
44
54
  desc: 'Skip graphql.config.yml and the .gitignore entries for the IDE helper files'
45
55
 
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.
56
+ # graphql-ruby's install: the app's, or one now, with graphql-ruby's defaults.
48
57
  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
58
+ return if schema_path
59
+
60
+ say_status :invoke, 'graphql:install (no graphql-ruby schema found)', :blue
61
+ invoke 'graphql:install', [], graphql_install_options
62
+ @schema_path = nil
63
+ return if schema_path
64
+
65
+ raise Thor::Error, "No graphql-ruby schema class (class #{options[:schema] || 'AppSchema'} < GraphQL::Schema) under app/: " \
66
+ 'run `bin/rails g graphql:install` first.'
51
67
  end
52
68
 
53
69
  def create_initializer
@@ -63,38 +79,60 @@ class Lighthouse::Generators::InstallGenerator < Rails::Generators::Base
63
79
  template 'schema.graphql.tt', File.join(sdl_folder, 'schema.graphql')
64
80
  end
65
81
 
66
- def create_schema_class
67
- return if keep_existing?(schema_path)
68
-
69
- template 'schema.rb.tt', schema_path, force: true
82
+ # Subscriptions::BaseSubscription, which graphql-ruby has none of (see BaseClasses).
83
+ def create_base_subscription
84
+ write_base_class(:subscriptions, Lighthouse::Config.namespaces[:subscriptions].first) if options[:subscriptions]
70
85
  end
71
86
 
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')
87
+ # graphql-ruby's schema class, built by Lighthouse from the SDL on its classes.
88
+ def adopt_schema_class
89
+ source = read(schema_path)
90
+ if lighthouse?(source)
91
+ say_status :skip, "#{schema_path} is Lighthouse's already", :yellow
92
+ return
93
+ end
77
94
 
78
- ROOT_TYPES.each { |path| remove_file path unless before?(path) }
95
+ declaration = /^([ \t]*)class (\S+) < GraphQL::Schema[ \t]*\n/
96
+ indent, name = source.match(declaration).captures
97
+ gsub_file schema_path, declaration, "#{indent}#{schema_comment(indent)}#{indent}class #{name} < #{schema_superclass(indent)}\n"
98
+ gsub_file schema_path, /^[ \t]*(query|mutation|subscription)\(Types::(Query|Mutation|Subscription)Type\)\n/, ''
99
+ gsub_file schema_path, /^(#{indent}\)\n)\n+/, '\\1'
100
+ gsub_file schema_path, RESOLVE_TYPE_STUB, "\n"
101
+ add_dataloader if source.include?('use GraphQL::Batch') && source.exclude?('use GraphQL::Dataloader')
79
102
  end
80
103
 
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/, ''
104
+ # The example field goes; the root types' own fields stay.
105
+ def adapt_root_types
106
+ %w[query_type mutation_type subscription_type].each do |name|
107
+ path = types_path("#{name}.rb")
108
+ gsub_file path, TEST_FIELD, "\n" if exist?(path) && read(path).match?(TEST_FIELD)
109
+ end
88
110
  end
89
111
 
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)
112
+ # Every type class is Lighthouse's: graphql-ruby's base object, interface,
113
+ # union and scalar include Lighthouse::GraphQL::Type, so the classes that
114
+ # inherit from them (Types::QueryType, Types::NodeType, what `graphql:object`
115
+ # writes) lend their methods to the SDL's types of their name.
116
+ def adapt_type_bases
117
+ TYPE_CONCERN_ANCHORS.each do |name, anchor|
118
+ path = types_path("#{name}.rb")
119
+ next if !exist?(path) || read(path).include?('Lighthouse::GraphQL::Type')
120
+
121
+ indent = read(path)[anchor, 1]
122
+ next unless indent
123
+
124
+ indent += ' ' unless name == 'base_interface'
125
+ inject_into_file path, "#{indent}# Lighthouse's: the class of the SDL type of its name lends that type its methods.\n" \
126
+ "#{indent}include Lighthouse::GraphQL::Type\n", after: anchor
127
+ end
93
128
  end
94
129
 
95
- def create_controller
96
- return remove_graphql_ruby_controller if options[:skip_controller]
97
- return if (@kept_controller = keep_existing?(CONTROLLER))
130
+ # graphql-ruby's controller, as its install wrote it, serves the schema with
131
+ # Lighthouse's concern. One the app has changed is left alone.
132
+ def adapt_controller
133
+ return if options[:skip_controller] || !exist?(CONTROLLER)
134
+ return if read(CONTROLLER).include?('Lighthouse::GraphQL::Controller')
135
+ return @kept_controller = true unless graphql_ruby_controller?(read(CONTROLLER))
98
136
 
99
137
  template 'graphql_controller.rb.tt', CONTROLLER, force: true
100
138
  end
@@ -106,8 +144,7 @@ class Lighthouse::Generators::InstallGenerator < Rails::Generators::Base
106
144
  return unless File.exist?(routes)
107
145
  return if File.read(routes).include?('graphql#execute')
108
146
 
109
- # Byte-identical to graphql-ruby's install route, so whichever generator
110
- # runs second finds it in place and does not add it again.
147
+ # Byte-identical to graphql-ruby's install route.
111
148
  route 'post "/graphql", to: "graphql#execute"'
112
149
  end
113
150
 
@@ -128,21 +165,22 @@ class Lighthouse::Generators::InstallGenerator < Rails::Generators::Base
128
165
  def show_next_steps
129
166
  serve =
130
167
  if @kept_controller
131
- "Serve it: #{CONTROLLER} was kept — have it call #{schema_name}.execute(...) to serve Lighthouse's schema."
168
+ "Serve it: #{CONTROLLER} was changed from graphql-ruby's, so it was kept — " \
169
+ "include Lighthouse::GraphQL::Controller in it, or keep calling #{schema_name}.execute(...)."
132
170
  else
133
171
  'Query it: POST /graphql {"query": "{ users { data { id name } paginatorInfo { total } } }"}'
134
172
  end
135
173
 
136
174
  say <<~MSG
137
175
 
138
- Lighthouse is installed. Next steps:
176
+ Lighthouse is installed on graphql-ruby's install (#{schema_path}). Next steps:
139
177
  1. Describe your API in #{sdl_folder}/*.graphql (the starter schema assumes a User model).
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)
178
+ 2. Resolve a field in Ruby when a directive is not enough, as in graphql-ruby: a method of
179
+ Types::QueryType (Query.latestPosts -> #latest_posts), a `field` of its own there,
180
+ `bin/rails g graphql:mutation`, or a Resolvers::BaseResolver subclass named after the field.
143
181
  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
182
+ bin/rails g graphql:object Post (or graphql:union, graphql:interface, graphql:scalar);
183
+ the class is Lighthouse's through its base (Lighthouse::GraphQL::Type)
146
184
  4. Generate the IDE helper files: bin/rails lighthouse:ide_helper
147
185
  5. #{serve}
148
186
  MSG
@@ -150,43 +188,26 @@ class Lighthouse::Generators::InstallGenerator < Rails::Generators::Base
150
188
 
151
189
  private
152
190
 
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.
155
- def keep_existing?(path)
156
- return false if options[:force] || !before?(path)
157
-
158
- say_status :skip, "#{path} already exists (pass --force to replace it)", :yellow
159
- true
160
- end
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')
191
+ def schema_superclass(indent)
192
+ lines = base_types.map { |key, name| "#{indent} #{key}: #{name}" }
193
+ "Lighthouse::GraphQL::RbLightHouse.get_schema(\n#{indent} options: {\n#{indent} base_types: {\n" \
194
+ "#{lines.join(",\n")}\n#{indent} }\n#{indent} }\n#{indent})"
181
195
  end
182
196
 
183
- def schema_name
184
- options[:schema].camelize
197
+ def schema_comment(indent)
198
+ <<~RUBY.gsub(/^/, indent).delete_prefix(indent)
199
+ # Lighthouse builds the schema from the SDL in #{sdl_folder}/, on the classes graphql-ruby's
200
+ # install wrote (base_types): the SDL's root types extend the code-first ones, whose
201
+ # `field`s stay the schema's, and every type, resolver and mutation class inherits from
202
+ # its kind's base. The rest of the class is graphql-ruby's.
203
+ RUBY
185
204
  end
186
205
 
187
- # `Resolvers::BaseResolver`, for the schema class template.
188
- def base_class_constant(kind)
189
- "#{Lighthouse::Config.namespaces[kind].first}::#{base_class_name(kind)}"
206
+ def add_dataloader
207
+ anchor = /^([ \t]*)use GraphQL::Batch\n/
208
+ indent = read(schema_path)[anchor, 1]
209
+ inject_into_file schema_path, "#{indent}# Lighthouse's directives batch through GraphQL::Dataloader, beside GraphQL::Batch.\n" \
210
+ "#{indent}use GraphQL::Dataloader\n", after: anchor
190
211
  end
191
212
 
192
213
  def sdl_folder
@@ -19,8 +19,9 @@ Lighthouse.configure do |config|
19
19
  # @event(dispatch:) classes come from :events, and the graphql-ruby analyzers
20
20
  # @complexity(resolver:) names from :analyzers. Post.readingTime, with no
21
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`.
22
+ # Types::BaseObject including Lighthouse::GraphQL::Type), from :types. Generate a
23
+ # subscription, builder or validator with `rails g lighthouse:subscription|builder|validator Name`,
24
+ # and a type class with graphql-ruby's `rails g graphql:object|union|interface|scalar Name`.
24
25
  # config.namespaces = {
25
26
  # queries: ['Resolvers'], mutations: ['Mutations'], subscriptions: ['Subscriptions'],
26
27
  # builders: ['Builders'], validators: ['Validators'], events: ['Events'], analyzers: ['Analyzers'],
@@ -33,13 +34,6 @@ Lighthouse.configure do |config|
33
34
  # Fully-qualified names (containing `::`) are used as-is.
34
35
  # config.resolver_namespaces = ['App::Graphql']
35
36
 
36
- # Store for @cache, @clearCache and @throttle's counts.
37
- # config.cache_store = Rails.cache
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
-
43
37
  # Run @create/@update/@createMany/@updateMany in a database transaction.
44
38
  # config.transactional_mutations = true
45
39
 
@@ -1,11 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'active_support/cache'
3
4
  require 'digest'
4
5
  require 'json'
5
6
  require 'securerandom'
6
7
 
7
- # Keys and invalidation for @cache / @clearCache over Config.cache_store
8
- # (Rails.cache by default).
8
+ # The app's cache stores, and keys and invalidation for @cache / @clearCache.
9
+ #
10
+ # Lighthouse keeps no store of its own, as PHP Lighthouse uses Laravel's: @cache,
11
+ # @clearCache and APQ use Rails.cache (config.cache_store), and @throttle counts
12
+ # where Rails' own `rate_limit` does, in the controller's cache_store.
9
13
  #
10
14
  # PHP Lighthouse invalidates with cache tags, which Rails cache stores do not
11
15
  # have (and `delete_matched` is not universal). Instead every entry key embeds
@@ -19,8 +23,10 @@ module Lighthouse::GraphQL::Caching
19
23
 
20
24
  module_function
21
25
 
26
+ # Rails.cache; outside Rails, one in-memory store for the process.
22
27
  def store
23
- Lighthouse::Config.cache_store
28
+ rails_cache = ::Rails.cache if defined?(::Rails) && ::Rails.respond_to?(:cache)
29
+ rails_cache || (@memory_store ||= ActiveSupport::Cache::MemoryStore.new)
24
30
  end
25
31
 
26
32
  def entry_key(type, id, field, args, user_key)
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'active_support/cache'
4
-
5
3
  module Lighthouse
6
4
  # Convenience entrypoint: `Lighthouse.configure { |c| c.resolver_namespaces = [...] }`.
7
5
  def self.configure(&block)
@@ -26,21 +24,21 @@ module Lighthouse
26
24
 
27
25
  # Every setting reset! forgets, so the next read falls back to its default.
28
26
  SETTINGS = %i[
29
- sdl_folder resolver_namespaces namespaces cache_store transactional_mutations pagination_default_count pagination_max_count
27
+ sdl_folder resolver_namespaces namespaces transactional_mutations pagination_default_count pagination_max_count
30
28
  schema_cache schema_cache_path watch_sdl persisted_queries persisted_queries_store persisted_queries_ttl
31
29
  persisted_queries_manifest persisted_queries_only on_error directive_map authenticated_user policy_user authorizer
32
30
  feature_checker feature_actor
33
31
  convert_empty_strings_to_null
34
32
  soft_deletes
35
33
  queue_broadcasts broadcast_queue
36
- global_id upload_store
34
+ global_id
37
35
  ].freeze
38
36
 
39
37
  class << self
40
- attr_writer :sdl_folder, :resolver_namespaces, :on_error, :authenticated_user, :policy_user, :authorizer, :cache_store,
38
+ attr_writer :sdl_folder, :resolver_namespaces, :on_error, :authenticated_user, :policy_user, :authorizer,
41
39
  :transactional_mutations, :schema_cache, :watch_sdl, :persisted_queries, :persisted_queries_store, :persisted_queries_only,
42
40
  :queue_broadcasts,
43
- :convert_empty_strings_to_null, :feature_checker, :feature_actor, :global_id, :upload_store
41
+ :convert_empty_strings_to_null, :feature_checker, :feature_actor, :global_id
44
42
 
45
43
  def sdl_folder
46
44
  @sdl_folder ||= (defined?(Rails) && Rails.respond_to?(:root) ? Rails.root.join('graphql').to_s : nil)
@@ -66,16 +64,6 @@ module Lighthouse
66
64
  @namespaces = namespaces.merge(overrides.to_h.transform_keys(&:to_sym).transform_values { |v| Array(v) })
67
65
  end
68
66
 
69
- # Store backing @cache and @clearCache. Defaults to Rails.cache.
70
- def cache_store
71
- @cache_store ||=
72
- if defined?(Rails) && Rails.respond_to?(:cache) && Rails.cache
73
- Rails.cache
74
- else
75
- ActiveSupport::Cache::MemoryStore.new
76
- end
77
- end
78
-
79
67
  # --- Subscriptions (used by @broadcast) ----------------------------------
80
68
  #
81
69
  # queue_broadcasts perform @broadcast triggers in a Lighthouse::GraphQL::BroadcastJob
@@ -91,7 +79,7 @@ module Lighthouse
91
79
  # schema_cache_path where; default Rails.root/tmp/cache/lighthouse
92
80
  # watch_sdl reload the app (and so the schema) when a .graphql file changes
93
81
  # persisted_queries accept Apollo's Automatic Persisted Queries (PersistedQueries)
94
- # persisted_queries_store where APQ keeps queries; default cache_store
82
+ # persisted_queries_store where APQ keeps queries; default Rails.cache
95
83
  # persisted_queries_ttl how long they live there (seconds; nil: until evicted)
96
84
  # persisted_queries_manifest a manifest of trusted operations, served by hash
97
85
  # persisted_queries_only reject any operation not in the manifest
@@ -110,13 +98,8 @@ module Lighthouse
110
98
  # and `decode(global_id)` -> [type, id]. Base64 of "Type:id" by default.
111
99
  def global_id = @global_id || Lighthouse::GraphQL::GlobalId
112
100
 
113
- # Stores the file an @upload argument carries: `call(file, disk:, path:,
114
- # public:)` returns what the argument holds instead. An ActiveStorage blob's
115
- # signed id by default.
116
- def upload_store = @upload_store || Lighthouse::GraphQL::UploadStore
117
-
118
101
  def persisted_queries_store
119
- @persisted_queries_store || cache_store
102
+ @persisted_queries_store || Lighthouse::GraphQL::Caching.store
120
103
  end
121
104
 
122
105
  # Pagination defaults, overridden by a directive's defaultCount / maxCount.
@@ -24,6 +24,11 @@ require 'json'
24
24
  # Cache-Control header the selected fields' @cacheControl hints allow, renders
25
25
  # the JSON and dispatches EndRequest. In development an error renders as JSON.
26
26
  #
27
+ # A field's @throttle that renders past its limit (its `with:` method, or
28
+ # `head :too_many_requests`, rate_limit's default on Rails 7.2) ends the request
29
+ # with that response, as a before_action that renders does: the operation stops
30
+ # there (Performed), and `execute` renders nothing more.
31
+ #
27
32
  # Hooks, private so they are not actions: `graphql_context` (default:
28
33
  # `{ current_user: current_user }` when the controller has a current_user, else
29
34
  # `{}`), `graphql_schema` and `graphql_variables(param)`. A controller with an
@@ -32,6 +37,11 @@ require 'json'
32
37
  module Lighthouse::GraphQL::Controller
33
38
  extend ActiveSupport::Concern
34
39
 
40
+ # Raised once a @throttle `with:` method has performed the response: control
41
+ # flow, as `throw :abort` in a before_action, so no `rescue StandardError` (a
42
+ # schema's rescue_from) catches it on its way out of the operation.
43
+ class Performed < Exception; end # rubocop:disable Lint/InheritException
44
+
35
45
  included do
36
46
  class_attribute :lighthouse_schema_source, instance_writer: false
37
47
  end
@@ -57,7 +67,7 @@ module Lighthouse::GraphQL::Controller
57
67
  end
58
68
  end
59
69
  rescue StandardError => e
60
- raise e unless defined?(::Rails) && ::Rails.env.development?
70
+ raise e unless defined?(::Rails) && ::Rails.env.development? && !rails_response?(e)
61
71
 
62
72
  lighthouse_development_error(e)
63
73
  end
@@ -65,13 +75,18 @@ module Lighthouse::GraphQL::Controller
65
75
  private
66
76
 
67
77
  # Dispatch StartRequest, run the block, dispatch EndRequest with the
68
- # response. Only the outermost call dispatches.
78
+ # response. Only the outermost call dispatches, and stops the block where a
79
+ # @throttle `with:` method performed the response.
69
80
  def lighthouse_request
70
81
  return yield if @lighthouse_request_started
71
82
 
72
83
  @lighthouse_request_started = true
73
84
  Lighthouse::Events.dispatch(Lighthouse::Events::StartRequest.new(request))
74
- result = yield
85
+ result = begin
86
+ yield
87
+ rescue Performed
88
+ nil
89
+ end
75
90
  Lighthouse::Events.dispatch(Lighthouse::Events::EndRequest.new(response))
76
91
  result
77
92
  end
@@ -81,9 +96,11 @@ module Lighthouse::GraphQL::Controller
81
96
  respond_to?(:current_user, true) ? { current_user: current_user } : {}
82
97
  end
83
98
 
84
- # graphql_context, with the client's IP (for @throttle) unless it sets its own.
99
+ # graphql_context, with what @throttle reads unless it sets its own: the
100
+ # client's IP (rate_limit's default by:) and the controller, whose cache_store,
101
+ # controller_path and methods rate_limit uses.
85
102
  def lighthouse_context
86
- { remote_ip: request.remote_ip }.merge(graphql_context)
103
+ { remote_ip: request.remote_ip, lighthouse_controller: self }.merge(graphql_context)
87
104
  end
88
105
 
89
106
  def graphql_schema
@@ -139,6 +156,12 @@ module Lighthouse::GraphQL::Controller
139
156
  Lighthouse::GraphQL::PersistedQueries.multiplex(graphql_schema, operations)
140
157
  end
141
158
 
159
+ # An error Rails answers with a status of its own (config.action_dispatch.rescue_responses):
160
+ # ActionController::TooManyRequests from @throttle is a 429 in development too.
161
+ def rails_response?(error)
162
+ ::ActionDispatch::ExceptionWrapper.status_code_for_exception(error.class.name) != 500
163
+ end
164
+
142
165
  def lighthouse_development_error(error)
143
166
  logger.error(error.message)
144
167
  logger.error(error.backtrace.join("\n"))
@@ -11,7 +11,7 @@ require_relative 'base'
11
11
  # @hash the value hashed with BCrypt, for a password column
12
12
  # @drop the value removed: the resolver never sees it
13
13
  # @bind(class:) the value replaced by the record(s) it identifies
14
- # @upload the uploaded file stored (Config.upload_store), its blob's signed id passed
14
+ # @upload the uploaded file stored with ActiveStorage (UploadStore), its blob's signed id passed
15
15
  #
16
16
  # type Mutation {
17
17
  # signUp(email: String! @trim, password: String! @trim @hash, captcha: String @drop): User! @create
@@ -60,26 +60,26 @@ class Lighthouse::GraphQL::DirectiveResolvers::ArgumentTransformsResolver < Ligh
60
60
  "A model class, or a class whose `call(value, context)` returns what the value stands for."
61
61
  class: String!
62
62
  "The column the value is matched against, for a model."
63
- column: String! = "id"
63
+ column: String = "id"
64
64
  "Relations to eager-load on the bound records."
65
- with: [String!]! = []
65
+ with: [String!] = []
66
66
  "Pass null instead of failing when no record matches."
67
- optional: Boolean! = false
67
+ optional: Boolean = false
68
68
  ) on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION
69
69
  GRAPHQL
70
70
  'upload' => <<~GRAPHQL.chomp
71
71
  """
72
- Store the file an Upload argument carries, and pass along what Config.upload_store
73
- answers instead: by default, the signed id of an ActiveStorage blob, which an
74
- attachment attribute (has_one_attached) attaches.
72
+ Store the file an Upload argument carries with ActiveStorage, and pass along the
73
+ signed id of its blob instead, which an attachment attribute (has_one_attached)
74
+ attaches. This does not change the schema from a client perspective.
75
75
  """
76
76
  directive @upload(
77
- "The storage service to store the file on (an ActiveStorage service name). Defaults to the default one."
77
+ "The ActiveStorage service to store the file on (config/storage.yml). Defaults to config.active_storage.service."
78
78
  disk: String
79
- "A prefix for the stored file's key."
80
- path: String! = "/"
81
- "Accepted for PHP Lighthouse parity: whether files are public is the service's setting."
82
- public: Boolean! = false
79
+ "The directory the stored file's key starts with."
80
+ path: String = "/"
81
+ "Whether the file is public. ActiveStorage sets that per service, so it must agree with the service's."
82
+ public: Boolean = false
83
83
  ) on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION
84
84
  GRAPHQL
85
85
  }.freeze
@@ -154,8 +154,8 @@ class Lighthouse::GraphQL::DirectiveResolvers::ArgumentTransformsResolver < Ligh
154
154
  send(TRANSFORMS.fetch(directive.name), directive, argument, value, ctx)
155
155
  end
156
156
 
157
- def decode_global_id(directive, _argument, value, _ctx)
158
- decode(value, Lighthouse::Support::DirectiveArgs.literal(directive, 'decode') || 'ARRAY')
157
+ def decode_global_id(directive, _argument, value, ctx)
158
+ decode(value, Lighthouse::Support::DirectiveArgs.literal(directive, 'decode') || 'ARRAY', ctx)
159
159
  end
160
160
 
161
161
  def trim_value(_directive, _argument, value, _ctx) = trim(value)
@@ -166,13 +166,13 @@ class Lighthouse::GraphQL::DirectiveResolvers::ArgumentTransformsResolver < Ligh
166
166
  return value.map { |item| upload(directive, argument, item, ctx) } if value.is_a?(Array)
167
167
 
168
168
  options = %w[disk path public].to_h { |name| [name.to_sym, Lighthouse::Support::DirectiveArgs.literal(directive, name)] }
169
- Lighthouse::Config.upload_store.call(value, **options.compact)
169
+ Lighthouse::GraphQL::UploadStore.call(value, argument: argument.graphql_name, **options)
170
170
  end
171
171
 
172
- def decode(value, mode)
173
- return value.map { |item| decode(item, mode) } if value.is_a?(Array)
172
+ def decode(value, mode, ctx = nil)
173
+ return value.map { |item| decode(item, mode, ctx) } if value.is_a?(Array)
174
174
 
175
- type, id = Lighthouse::GraphQL::GlobalId.decode!(value)
175
+ type, id = Lighthouse::GraphQL::GlobalId.decode!(value, ctx)
176
176
  { 'TYPE' => type, 'ID' => id }.fetch(mode.to_s, [type, id])
177
177
  end
178
178
 
@@ -3,7 +3,7 @@
3
3
  require_relative 'base'
4
4
 
5
5
  module Lighthouse::GraphQL::DirectiveResolvers
6
- # @cache — keep a field's resolved value in Config.cache_store (Rails.cache).
6
+ # @cache — keep a field's resolved value in Rails.cache (Caching.store).
7
7
  #
8
8
  # The key covers the parent type and its key (the field marked @cacheKey,
9
9
  # else the first `ID` field; none on Query/Mutation), the field, and its
@@ -51,7 +51,9 @@ class Lighthouse::GraphQL::DirectiveResolvers::GlobalIdDirectiveResolver < Light
51
51
 
52
52
  install(target_type_class, lambda do |obj, args, ctx|
53
53
  id = inner.call(obj, args, ctx)
54
- id.nil? ? nil : Lighthouse::Config.global_id.encode(type_name, id)
54
+ next if id.nil?
55
+
56
+ Lighthouse::GraphQL::GlobalId.encode_field(target_type_class, obj, id, ctx) || Lighthouse::Config.global_id.encode(type_name, id)
55
57
  end)
56
58
  end
57
59
  end