lighthouse-graphql 0.5.0 → 0.7.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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +212 -1
  3. data/README.md +38 -22
  4. data/docs/best-practices.md +6 -4
  5. data/docs/caching.md +4 -3
  6. data/docs/configuration.md +20 -26
  7. data/docs/custom-directives.md +2 -1
  8. data/docs/directives.md +79 -14
  9. data/docs/federation.md +1 -1
  10. data/docs/filtering-and-ordering.md +330 -30
  11. data/docs/generators.md +42 -40
  12. data/docs/getting-started.md +128 -58
  13. data/docs/motivation.md +3 -2
  14. data/docs/performance.md +5 -4
  15. data/docs/relationships.md +7 -5
  16. data/docs/relay.md +63 -0
  17. data/docs/resolvers.md +92 -39
  18. data/docs/schema-organisation.md +177 -0
  19. data/docs/security.md +64 -16
  20. data/docs/subscriptions.md +16 -9
  21. data/docs/types.md +16 -17
  22. data/docs/uploads.md +41 -14
  23. data/lib/generators/lighthouse/base_classes.rb +9 -14
  24. data/lib/generators/lighthouse/install/graphql_ruby_install.rb +113 -0
  25. data/lib/generators/lighthouse/install/install_generator.rb +125 -99
  26. data/lib/generators/lighthouse/install/templates/graphql.config.yml.tt +2 -1
  27. data/lib/generators/lighthouse/install/templates/initializer.rb.tt +14 -13
  28. data/lib/lighthouse/ast_builder.rb +124 -0
  29. data/lib/lighthouse/caching.rb +9 -3
  30. data/lib/lighthouse/config.rb +27 -25
  31. data/lib/lighthouse/contracts/arg_manipulator.rb +9 -5
  32. data/lib/lighthouse/contracts/field_builder.rb +13 -0
  33. data/lib/lighthouse/controller.rb +28 -5
  34. data/lib/lighthouse/directive_resolvers/aggregate_resolver.rb +1 -2
  35. data/lib/lighthouse/directive_resolvers/all_resolver.rb +0 -2
  36. data/lib/lighthouse/directive_resolvers/argument_transforms_resolver.rb +18 -18
  37. data/lib/lighthouse/directive_resolvers/association_resolvers.rb +9 -6
  38. data/lib/lighthouse/directive_resolvers/base.rb +6 -37
  39. data/lib/lighthouse/directive_resolvers/builder_applier.rb +21 -6
  40. data/lib/lighthouse/directive_resolvers/cache_resolvers.rb +1 -1
  41. data/lib/lighthouse/directive_resolvers/count_resolver.rb +0 -1
  42. data/lib/lighthouse/directive_resolvers/first_resolver.rb +0 -2
  43. data/lib/lighthouse/directive_resolvers/global_id_resolver.rb +3 -1
  44. data/lib/lighthouse/directive_resolvers/paginate_resolver.rb +2 -3
  45. data/lib/lighthouse/directive_resolvers/throttle_resolver.rb +85 -26
  46. data/lib/lighthouse/directive_resolvers/type_method_resolver.rb +12 -6
  47. data/lib/lighthouse/directives/arguments/more_filters.rb +1 -1
  48. data/lib/lighthouse/directives/arguments/order_by.rb +190 -26
  49. data/lib/lighthouse/directives/arguments/where_conditions.rb +25 -0
  50. data/lib/lighthouse/directives/arguments/where_conditions_base.rb +84 -0
  51. data/lib/lighthouse/directives/arguments/where_has_conditions.rb +56 -0
  52. data/lib/lighthouse/directives/definitions.rb +0 -1
  53. data/lib/lighthouse/directives/generated_types.rb +103 -0
  54. data/lib/lighthouse/directives/registry.rb +39 -10
  55. data/lib/lighthouse/document_ast.rb +111 -0
  56. data/lib/lighthouse/field_resolver_stack.rb +9 -6
  57. data/lib/lighthouse/global_id.rb +29 -3
  58. data/lib/lighthouse/graphql/version.rb +1 -1
  59. data/lib/lighthouse/graphql_ruby_patches.rb +45 -0
  60. data/lib/lighthouse/ide_helper.rb +80 -23
  61. data/lib/lighthouse/namespaces.rb +1 -0
  62. data/lib/lighthouse/order_by/types.rb +137 -0
  63. data/lib/lighthouse/railtie.rb +9 -2
  64. data/lib/lighthouse/rb_lighthouse.rb +16 -5
  65. data/lib/lighthouse/resolve_info.rb +5 -5
  66. data/lib/lighthouse/resolver_bases.rb +18 -4
  67. data/lib/lighthouse/resolver_dispatch.rb +16 -1
  68. data/lib/lighthouse/root_types.rb +117 -0
  69. data/lib/lighthouse/schema_cache.rb +8 -4
  70. data/lib/lighthouse/schema_factory.rb +6 -9
  71. data/lib/lighthouse/schema_generator.rb +8 -18
  72. data/lib/lighthouse/schema_stitcher.rb +64 -0
  73. data/lib/lighthouse/support/naming.rb +0 -12
  74. data/lib/lighthouse/upload.rb +4 -2
  75. data/lib/lighthouse/upload_store.rb +39 -14
  76. data/lib/lighthouse/where_conditions/columns.rb +36 -0
  77. data/lib/lighthouse/where_conditions/handler.rb +152 -0
  78. data/lib/lighthouse/where_conditions/json_column.rb +99 -0
  79. data/lib/lighthouse/where_conditions/operator_map.rb +1 -4
  80. data/lib/lighthouse/where_conditions/types.rb +110 -0
  81. data/sig/lighthouse/directives.rbs +16 -3
  82. data/sig/lighthouse/graphql.rbs +48 -4
  83. data/sig/lighthouse.rbs +8 -12
  84. metadata +17 -7
  85. data/lib/generators/lighthouse/install/templates/schema.rb.tt +0 -29
  86. data/lib/generators/lighthouse/mutation_generator.rb +0 -31
  87. data/lib/generators/lighthouse/query_generator.rb +0 -30
  88. data/lib/lighthouse/directive_resolvers/where_conditions_applier.rb +0 -170
  89. data/lib/lighthouse/directives/where_conditions_directive.rb +0 -244
  90. data/lib/lighthouse/sdl_loader.rb +0 -22
@@ -3,11 +3,13 @@
3
3
  # Lighthouse configuration. Every option is optional; the defaults are shown.
4
4
  # Reference: https://github.com/Vaz-Innovation/lighthouse-graphql/blob/main/docs/configuration.md
5
5
  Lighthouse.configure do |config|
6
- # Folder the *.graphql schema files are loaded from.
7
- <% if default_sdl_folder? -%>
8
- # config.sdl_folder = Rails.root.join('graphql').to_s
6
+ # The schema's entrypoint. It imports the other .graphql files
7
+ # (#import user.graphql, #import blog/*.graphql), which add to a type
8
+ # defined elsewhere with `extend type`.
9
+ <% if default_sdl_schema_path? -%>
10
+ # config.schema_path = Rails.root.join('graphql/schema.graphql').to_s
9
11
  <% else -%>
10
- config.sdl_folder = Rails.root.join('<%= sdl_folder %>').to_s
12
+ config.schema_path = Rails.root.join('<%= sdl_schema_path %>').to_s
11
13
  <% end -%>
12
14
 
13
15
  # Where classes named by the schema are looked up, by kind. A Query or Mutation
@@ -19,8 +21,9 @@ Lighthouse.configure do |config|
19
21
  # @event(dispatch:) classes come from :events, and the graphql-ruby analyzers
20
22
  # @complexity(resolver:) names from :analyzers. Post.readingTime, with no
21
23
  # 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`.
24
+ # Types::BaseObject including Lighthouse::GraphQL::Type), from :types. Generate a
25
+ # subscription, builder or validator with `rails g lighthouse:subscription|builder|validator Name`,
26
+ # and a type class with graphql-ruby's `rails g graphql:object|union|interface|scalar Name`.
24
27
  # config.namespaces = {
25
28
  # queries: ['Resolvers'], mutations: ['Mutations'], subscriptions: ['Subscriptions'],
26
29
  # builders: ['Builders'], validators: ['Validators'], events: ['Events'], analyzers: ['Analyzers'],
@@ -33,13 +36,6 @@ Lighthouse.configure do |config|
33
36
  # Fully-qualified names (containing `::`) are used as-is.
34
37
  # config.resolver_namespaces = ['App::Graphql']
35
38
 
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
39
  # Run @create/@update/@createMany/@updateMany in a database transaction.
44
40
  # config.transactional_mutations = true
45
41
 
@@ -64,6 +60,11 @@ Lighthouse.configure do |config|
64
60
  # Reload the app (and so the schema) when a .graphql file changes.
65
61
  # config.watch_sdl = Rails.env.development?
66
62
 
63
+ # Rewrite schema-directives.graphql and programmatic-types.graphql, which your
64
+ # IDE reads through graphql.config.yml, on boot and on every reload. nil: in
65
+ # development, when the app has a graphql.config.yml.
66
+ # config.ide_helper = nil
67
+
67
68
  # Automatic Persisted Queries (Apollo's protocol). Add a GET route too, so
68
69
  # hashed queries can be cached by CDNs: get "/graphql", to: "graphql#execute"
69
70
  # config.persisted_queries = true
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The manipulation phase, as PHP Lighthouse's ASTBuilder runs it on the
4
+ # stitched schema:
5
+ #
6
+ # 1. DocumentAST.from_source: every type defined once.
7
+ # 2. Type extensions merged into the types they extend. An extension of a
8
+ # root type that has no definition (`extend type Query` alone) defines it.
9
+ # 3. The type and field manipulators: @hide/@show, @model, @paginate (and the
10
+ # relation directives' `type:`), @softDeletes, @node.
11
+ # 4. The argument manipulators: each argument directive whose class includes
12
+ # Contracts::ArgManipulator rewrites its argument (@whereConditions,
13
+ # @whereHasConditions, @orderBy turn `_` into the input types they generate).
14
+ # 5. The definitions of directives the generated types use (`@enum`).
15
+ #
16
+ # Returns the manipulated SDL; ManipulateAST listeners then see its document.
17
+ module Lighthouse::GraphQL::ASTBuilder
18
+ Nodes = ::GraphQL::Language::Nodes
19
+
20
+ EXTENSION_TO_DEFINITION = {
21
+ Nodes::ObjectTypeExtension => Nodes::ObjectTypeDefinition,
22
+ Nodes::InterfaceTypeExtension => Nodes::InterfaceTypeDefinition,
23
+ Nodes::InputObjectTypeExtension => Nodes::InputObjectTypeDefinition,
24
+ Nodes::EnumTypeExtension => Nodes::EnumTypeDefinition,
25
+ Nodes::UnionTypeExtension => Nodes::UnionTypeDefinition,
26
+ Nodes::ScalarTypeExtension => Nodes::ScalarTypeDefinition
27
+ }.freeze
28
+
29
+ # What each extension adds to its definition, besides directives.
30
+ MERGED_LISTS = {
31
+ Nodes::ObjectTypeExtension => %i[fields interfaces],
32
+ Nodes::InterfaceTypeExtension => %i[fields interfaces],
33
+ Nodes::InputObjectTypeExtension => %i[fields],
34
+ Nodes::EnumTypeExtension => %i[values],
35
+ Nodes::UnionTypeExtension => %i[types],
36
+ Nodes::ScalarTypeExtension => []
37
+ }.freeze
38
+
39
+ ROOT_TYPES = %w[Query Mutation Subscription].freeze
40
+
41
+ OBJECT_LIKE = [Nodes::ObjectTypeDefinition, Nodes::InterfaceTypeDefinition].freeze
42
+
43
+ module_function
44
+
45
+ def build(sdl, registry: Lighthouse::DirectiveRegistry.default)
46
+ document_ast = Lighthouse::GraphQL::DocumentAST.from_source(sdl)
47
+ extend_types(document_ast)
48
+
49
+ document_ast = Lighthouse::GraphQL::DocumentAST.from_source(registry.manipulate_sdl(document_ast.to_sdl))
50
+ apply_arg_manipulators(document_ast, registry)
51
+
52
+ Lighthouse::GraphQL::SchemaGenerator.inject_directive_definitions(document_ast.to_sdl)
53
+ end
54
+
55
+ def extend_types(document_ast)
56
+ document_ast.type_extensions.each do |name, extensions|
57
+ extensions.each { |extension| extend_type(document_ast, name, extension) }
58
+ end
59
+ document_ast.type_extensions.clear
60
+ end
61
+
62
+ def extend_type(document_ast, name, extension)
63
+ definition = document_ast.types[name] || root_type(document_ast, name, extension)
64
+ unless definition.is_a?(EXTENSION_TO_DEFINITION.fetch(extension.class))
65
+ raise Lighthouse::DefinitionError,
66
+ "The type extension #{name} of kind #{kind(extension)} can not extend a definition of kind #{kind(definition)}."
67
+ end
68
+
69
+ merged = MERGED_LISTS.fetch(extension.class).index_with { |list| merge_unique(definition.public_send(list), extension.public_send(list)) }
70
+ document_ast.put_type(definition.merge(directives: definition.directives + extension.directives, **merged))
71
+ end
72
+
73
+ # `extend type Query` with no `type Query` anywhere defines it, as in PHP Lighthouse.
74
+ def root_type(document_ast, name, extension)
75
+ unless ROOT_TYPES.include?(name) && extension.is_a?(Nodes::ObjectTypeExtension)
76
+ raise Lighthouse::DefinitionError, "Could not find a base definition #{name} of kind #{kind(extension)} to extend."
77
+ end
78
+
79
+ Nodes::ObjectTypeDefinition.new(name: name, fields: [], interfaces: [], directives: []).tap { |root| document_ast.put_type(root) }
80
+ end
81
+
82
+ # The definition's nodes followed by the extension's; a name in both is an error.
83
+ def merge_unique(original, addition)
84
+ names = addition.to_set(&:name)
85
+ duplicate = original.find { |node| names.include?(node.name) }
86
+ raise Lighthouse::DefinitionError, "Duplicate definition #{duplicate.name} found when merging." if duplicate
87
+
88
+ original + addition
89
+ end
90
+
91
+ def kind(node)
92
+ node.class.name.demodulize
93
+ end
94
+
95
+ # Every argument of every object and interface field, through the
96
+ # manipulators of the directives it carries, in their SDL order.
97
+ def apply_arg_manipulators(document_ast, registry)
98
+ document_ast.type_names.each do |name|
99
+ type = document_ast.types[name]
100
+ next unless OBJECT_LIKE.any? { |kind| type.is_a?(kind) }
101
+
102
+ fields = type.fields.map do |field|
103
+ field.merge(arguments: field.arguments.map { |arg| manipulate_argument(document_ast, registry, arg, field, type) })
104
+ end
105
+ document_ast.put_type(type.merge(fields: fields))
106
+ end
107
+ end
108
+
109
+ def manipulate_argument(document_ast, registry, arg, field, type)
110
+ manipulated = arg.directives.reduce(arg) do |current, directive|
111
+ manipulator = registry.arg_manipulator_class(directive.name)
112
+ manipulator ? manipulator.new(current, directive).manipulate_arg_definition(document_ast, field, type) : current
113
+ end
114
+ return manipulated unless placeholder?(manipulated.type)
115
+
116
+ raise Lighthouse::DefinitionError,
117
+ "#{type.name}.#{field.name}(#{arg.name}:) has the placeholder type `_`, which only a directive that generates " \
118
+ 'its type replaces: @whereConditions, @whereHasConditions or @orderBy.'
119
+ end
120
+
121
+ def placeholder?(type)
122
+ Lighthouse::GraphQL::Directives::GeneratedTypes.placeholder?(type)
123
+ end
124
+ end
@@ -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,24 +24,43 @@ 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
+ schema_path ide_helper 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 :schema_path, :ide_helper, :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
42
+
43
+ # The schema's entrypoint, as PHP Lighthouse's `schema_path`: the one file
44
+ # the schema is read from, importing the others (`#import user.graphql`,
45
+ # `#import blog/*.graphql`; see SchemaStitcher). RAILS_ROOT/graphql/schema.graphql
46
+ # by default.
47
+ def schema_path
48
+ @schema_path ||= (::Rails.root.join('graphql/schema.graphql').to_s if defined?(::Rails) && ::Rails.respond_to?(:root) && ::Rails.root)
49
+ end
50
+
51
+ # The folder of the schema's entrypoint, which watch_sdl watches.
52
+ def schema_directory
53
+ schema_path && File.dirname(schema_path.to_s)
54
+ end
55
+
56
+ # Rewrite the IDE helper files (schema-directives.graphql,
57
+ # programmatic-types.graphql) on boot and on every reload. nil (the
58
+ # default): in development, when the app has a graphql.config.yml.
59
+ def ide_helper
60
+ return @ide_helper unless @ide_helper.nil?
44
61
 
45
- def sdl_folder
46
- @sdl_folder ||= (defined?(Rails) && Rails.respond_to?(:root) ? Rails.root.join('graphql').to_s : nil)
62
+ defined?(::Rails) && ::Rails.respond_to?(:env) && ::Rails.env.development? && ::Rails.root &&
63
+ File.exist?(::Rails.root.join('graphql.config.yml'))
47
64
  end
48
65
 
49
66
  def directive_map = @directive_map ||= {}
@@ -66,16 +83,6 @@ module Lighthouse
66
83
  @namespaces = namespaces.merge(overrides.to_h.transform_keys(&:to_sym).transform_values { |v| Array(v) })
67
84
  end
68
85
 
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
86
  # --- Subscriptions (used by @broadcast) ----------------------------------
80
87
  #
81
88
  # queue_broadcasts perform @broadcast triggers in a Lighthouse::GraphQL::BroadcastJob
@@ -91,7 +98,7 @@ module Lighthouse
91
98
  # schema_cache_path where; default Rails.root/tmp/cache/lighthouse
92
99
  # watch_sdl reload the app (and so the schema) when a .graphql file changes
93
100
  # persisted_queries accept Apollo's Automatic Persisted Queries (PersistedQueries)
94
- # persisted_queries_store where APQ keeps queries; default cache_store
101
+ # persisted_queries_store where APQ keeps queries; default Rails.cache
95
102
  # persisted_queries_ttl how long they live there (seconds; nil: until evicted)
96
103
  # persisted_queries_manifest a manifest of trusted operations, served by hash
97
104
  # persisted_queries_only reject any operation not in the manifest
@@ -110,13 +117,8 @@ module Lighthouse
110
117
  # and `decode(global_id)` -> [type, id]. Base64 of "Type:id" by default.
111
118
  def global_id = @global_id || Lighthouse::GraphQL::GlobalId
112
119
 
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
120
  def persisted_queries_store
119
- @persisted_queries_store || cache_store
121
+ @persisted_queries_store || Lighthouse::GraphQL::Caching.store
120
122
  end
121
123
 
122
124
  # Pagination defaults, overridden by a directive's defaultCount / maxCount.
@@ -2,12 +2,16 @@
2
2
 
3
3
  require_relative 'directive'
4
4
 
5
- # A directive on a field ARGUMENT that mutates the schema AST before build,
6
- # e.g. @whereConditions / @whereHasConditions generating input/enum types and
7
- # swapping the `_` placeholder argument type.
5
+ # A directive on a field ARGUMENT that rewrites the schema before it is built,
6
+ # as PHP Lighthouse's ArgManipulator: @whereConditions, @whereHasConditions and
7
+ # @orderBy generate the input types of an argument written with the `_`
8
+ # placeholder, and type it with them.
8
9
  #
9
- # Implementors provide (at the class level)
10
- # `manipulate_argument_definition(manipulation_context, parent, field, arg, directive)`.
10
+ # Implementors, built with (arg_node, directive_node), provide
11
+ # `#manipulate_arg_definition(document_ast, field_node, type_node)` returning
12
+ # the argument node (graphql-ruby's nodes are immutable: `arg.merge(type: ...)`).
13
+ # New types go in the Lighthouse::GraphQL::DocumentAST (`define_type`).
14
+ # ASTBuilder calls it for every argument of an object or interface field.
11
15
  module Lighthouse::Contracts::ArgManipulator
12
16
  include Lighthouse::Contracts::Directive
13
17
  end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'directive'
4
+
5
+ # A directive on a FIELD that constrains the query of the field's resolver
6
+ # whatever the client sends, e.g. @orderBy(column:, direction:) on a field.
7
+ # Applied after the argument builders, as PHP Lighthouse's FieldBuilderDirective.
8
+ #
9
+ # Implementors, built with (field_node, directive_node), provide
10
+ # `#handle_field_builder(relation)` returning a relation.
11
+ module Lighthouse::Contracts::FieldBuilder
12
+ include Lighthouse::Contracts::Directive
13
+ end
@@ -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"))
@@ -25,13 +25,12 @@ class Lighthouse::GraphQL::DirectiveResolvers::AggregateDirectiveResolver < Ligh
25
25
  column_name = extract_directive_arg('column')
26
26
  function_name = extract_directive_arg('function')
27
27
  resolver_instance = self
28
- field_ast_node = @field_def.ast_node
29
28
 
30
29
  install(target_type_class, lambda do |obj, args, ctx|
31
30
  relation = resolver_instance.send(:aggregate_source, obj, model_name, relation_name)
32
31
  return nil unless relation
33
32
 
34
- relation = Lighthouse::GraphQL::DirectiveResolvers::BuilderApplier.apply(relation, field_ast_node, args, ctx, resolver_instance)
33
+ relation = resolver_instance.apply_where_filters(relation, args, ctx)
35
34
  resolver_instance.send(:compute_aggregation, relation, column_name, function_name)
36
35
  end)
37
36
  end
@@ -25,8 +25,6 @@ class Lighthouse::GraphQL::DirectiveResolvers::AllDirectiveResolver < Lighthouse
25
25
  args = arguments.respond_to?(:to_h) ? arguments.to_h : {}
26
26
  relation = apply_scopes(model_class.all)
27
27
 
28
- relation = Lighthouse::GraphQL::DirectiveResolvers::BuilderApplier.apply(relation, @field_def.ast_node, args, ctx, self)
29
-
30
28
  relation = apply_where_filters(relation, args, ctx)
31
29
  relation = relation.limit(args[:first]) if args[:first]
32
30
  relation = relation.offset(args[:offset]) if args[:offset]
@@ -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
 
@@ -36,7 +36,7 @@ module Lighthouse::GraphQL::DirectiveResolvers
36
36
  return nil unless obj.respond_to?(name)
37
37
 
38
38
  return paginate(obj, name, args, ctx) if pagination_type
39
- return constrained(association_scope(obj, name), args) if constrained?(args)
39
+ return constrained(association_scope(obj, name), args, ctx) if constrained?(args)
40
40
 
41
41
  dataloader = Lighthouse::Support::Batching.dataloader(ctx)
42
42
  return obj.public_send(name) unless dataloader && obj.class.respond_to?(:reflect_on_association)
@@ -52,11 +52,14 @@ module Lighthouse::GraphQL::DirectiveResolvers
52
52
  self.class.collection? && directive_arg('type')&.to_s&.upcase
53
53
  end
54
54
 
55
- # Scopes or a supplied argument with a filter directive need a query of
56
- # their own instead of the batched load.
55
+ # Scopes, a query directive on the field (@builder, @orderBy(column:)) or a
56
+ # supplied argument with a filter directive need a query of their own
57
+ # instead of the batched load.
57
58
  def constrained?(args)
58
- return true if directive_arg('scopes')
59
+ directive_arg('scopes') || Lighthouse::GraphQL::DirectiveResolvers::BuilderApplier.any?(@field_def.ast_node) || filtered?(args)
60
+ end
59
61
 
62
+ def filtered?(args)
60
63
  registry = Lighthouse::DirectiveRegistry.default
61
64
  (@field_def.ast_node&.arguments || []).any? do |argument|
62
65
  args.key?(argument.name.underscore.to_sym) && argument.directives.any? { |d| filter_directive?(registry, d) }
@@ -68,8 +71,8 @@ module Lighthouse::GraphQL::DirectiveResolvers
68
71
  registry.arg_builder_class(directive.name) || CONDITION_DIRECTIVES.include?(directive.name)
69
72
  end
70
73
 
71
- def constrained(relation, args)
72
- relation = apply_where_filters(scoped(relation), args)
74
+ def constrained(relation, args, ctx)
75
+ relation = apply_where_filters(scoped(relation), args, ctx)
73
76
  self.class.collection? ? relation : relation.first
74
77
  end
75
78
 
@@ -18,12 +18,7 @@ class Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
18
18
 
19
19
  args_h = args.respond_to?(:to_h) ? args.to_h : (args || {})
20
20
 
21
- relation = apply_where_conditions(relation, args_h)
22
- @field_def.ast_node.arguments.each do |arg_node|
23
- relation = apply_where_has_conditions(relation, arg_node, args_h)
24
- end
25
-
26
- apply_arg_builders(relation, args_h, ctx)
21
+ apply_field_builders(apply_arg_builders(relation, args_h, ctx), args_h, ctx)
27
22
  rescue ::GraphQL::ExecutionError
28
23
  raise
29
24
  rescue StandardError => e
@@ -32,7 +27,7 @@ class Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
32
27
  end
33
28
 
34
29
  # Compose argument-builder directives (@eq, @neq, @in, @notIn, @like,
35
- # @where, @orderBy) onto the relation, in SDL argument order, for each
30
+ # @where, @orderBy, @whereConditions, @whereHasConditions) onto the relation, in SDL argument order, for each
36
31
  # field argument that carries one and that the client supplied a value
37
32
  # for — and for the input fields of an argument carrying @nest.
38
33
  def apply_arg_builders(relation, args_h, ctx = nil)
@@ -51,18 +46,6 @@ class Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
51
46
  relation
52
47
  end
53
48
 
54
- def extract_relation_name_from_directive(arg_node)
55
- directive = arg_node.directives.find { |dir| dir.name == 'whereHasConditions' }
56
- return nil unless directive
57
-
58
- relation_arg = directive.arguments.find { |arg| arg.name == 'relation' }
59
- return nil unless relation_arg
60
-
61
- relation_arg.value.respond_to?(:value) ? relation_arg.value.value : relation_arg.value
62
- rescue StandardError
63
- nil
64
- end
65
-
66
49
  def constantize_model(name)
67
50
  Lighthouse::Support::ModelResolver.constantize(name)
68
51
  end
@@ -113,24 +96,10 @@ class Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
113
96
  @field_def.type.unwrap.graphql_name.sub(/(SimplePaginator|Paginator|Connection)\z/, '')
114
97
  end
115
98
 
116
- # The `where:` argument (@whereConditions), when the client supplied one.
117
- def apply_where_conditions(relation, args_h)
118
- where_condition = args_h[:where] || args_h['where']
119
- return relation unless where_condition
120
-
121
- Lighthouse::GraphQL::DirectiveResolvers::WhereConditionsApplier.apply(relation, where_condition)
122
- end
123
-
124
- # A `whereHasConditions` argument the client supplied filters the
125
- # relation by the related records matching its condition.
126
- def apply_where_has_conditions(relation, arg_node, args_h)
127
- return relation unless arg_node.directives.any? { |dir| dir.name == 'whereHasConditions' }
128
-
129
- arg_value = Lighthouse::Support::Naming.fetch_arg(args_h, arg_node.name)
130
- return relation unless arg_value
131
-
132
- relation_name = extract_relation_name_from_directive(arg_node) || Lighthouse::Support::Naming.derive_relation_name(arg_node.name)
133
- Lighthouse::GraphQL::DirectiveResolvers::WhereConditionsApplier.apply_has_argument(relation, arg_node.name, arg_value, relation_name)
99
+ # The field's own query directives (@builder, @whereAuth, @orderBy(column:)),
100
+ # in SDL order, after the arguments', as PHP Lighthouse's enhanceBuilder applies them.
101
+ def apply_field_builders(relation, args_h = {}, ctx = nil)
102
+ Lighthouse::GraphQL::DirectiveResolvers::BuilderApplier.apply(relation, @field_def.ast_node, args_h, ctx, self)
134
103
  end
135
104
 
136
105
  # Every (argument, directive, builder class) on the field, in SDL order.