lighthouse-graphql 0.2.1 → 0.4.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +99 -1
- data/README.md +22 -3
- data/docs/best-practices.md +16 -6
- data/docs/caching.md +1 -1
- data/docs/configuration.md +74 -5
- data/docs/custom-directives.md +11 -1
- data/docs/directives.md +333 -2
- data/docs/events.md +203 -0
- data/docs/federation.md +3 -2
- data/docs/getting-started.md +90 -23
- data/docs/motivation.md +3 -2
- data/docs/mutations.md +15 -0
- data/docs/resolvers.md +95 -19
- data/docs/security.md +210 -0
- data/docs/subscriptions.md +446 -0
- data/lib/generators/lighthouse/base_classes.rb +87 -0
- data/lib/generators/lighthouse/class_generator.rb +19 -8
- data/lib/generators/lighthouse/install/install_generator.rb +23 -6
- data/lib/generators/lighthouse/install/templates/graphql_controller.rb.tt +7 -45
- data/lib/generators/lighthouse/install/templates/initializer.rb.tt +34 -5
- data/lib/generators/lighthouse/install/templates/schema.rb.tt +14 -2
- data/lib/generators/lighthouse/mutation_generator.rb +5 -4
- data/lib/generators/lighthouse/query_generator.rb +5 -4
- data/lib/generators/lighthouse/subscription_generator.rb +100 -0
- data/lib/lighthouse/broadcast_job.rb +14 -0
- data/lib/lighthouse/broadcasting.rb +143 -0
- data/lib/lighthouse/cache_control.rb +5 -2
- data/lib/lighthouse/complexity.rb +174 -0
- data/lib/lighthouse/complexity_analyzer.rb +56 -0
- data/lib/lighthouse/config.rb +56 -14
- data/lib/lighthouse/controller.rb +131 -0
- data/lib/lighthouse/directive_resolvers/broadcast_resolver.rb +31 -0
- data/lib/lighthouse/directive_resolvers/convention_resolver.rb +14 -4
- data/lib/lighthouse/directive_resolvers/convert_empty_strings_resolver.rb +134 -0
- data/lib/lighthouse/directive_resolvers/event_resolver.rb +37 -0
- data/lib/lighthouse/directive_resolvers/model_mutation_resolver.rb +28 -13
- data/lib/lighthouse/directive_resolvers/soft_delete_resolvers.rb +66 -0
- data/lib/lighthouse/directive_resolvers/subscription_resolver.rb +76 -0
- data/lib/lighthouse/directives/arguments/soft_deletes.rb +112 -0
- data/lib/lighthouse/directives/registry.rb +15 -6
- data/lib/lighthouse/directives/soft_deletes_directive.rb +123 -0
- data/lib/lighthouse/events/execution_trace.rb +52 -0
- data/lib/lighthouse/events/lifecycle.rb +148 -0
- data/lib/lighthouse/events/schema_build.rb +44 -0
- data/lib/lighthouse/events.rb +76 -0
- data/lib/lighthouse/feature_directive.rb +137 -0
- data/lib/lighthouse/field_resolver_stack.rb +28 -7
- data/lib/lighthouse/graphql/version.rb +1 -1
- data/lib/lighthouse/namespaces.rb +1 -0
- data/lib/lighthouse/persisted_queries/batch.rb +34 -0
- data/lib/lighthouse/persisted_queries.rb +17 -4
- data/lib/lighthouse/rb_lighthouse.rb +23 -0
- data/lib/lighthouse/resolver_bases.rb +61 -0
- data/lib/lighthouse/schema_cache.rb +12 -5
- data/lib/lighthouse/schema_factory.rb +0 -1
- data/lib/lighthouse/schema_generator.rb +42 -9
- data/lib/lighthouse/sdl_loader.rb +0 -2
- data/lib/lighthouse/subscription.rb +120 -0
- data/lib/lighthouse/support/model_resolver.rb +1 -1
- data/lib/lighthouse/support/soft_deletes.rb +164 -0
- data/lib/lighthouse/tasks/lighthouse.rake +10 -0
- data/sig/lighthouse/controller.rbs +30 -0
- data/sig/lighthouse/events.rbs +122 -0
- data/sig/lighthouse/graphql.rbs +62 -0
- data/sig/lighthouse.rbs +52 -1
- metadata +29 -1
|
@@ -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
|
data/lib/lighthouse/config.rb
CHANGED
|
@@ -12,12 +12,15 @@ module Lighthouse
|
|
|
12
12
|
# Where each kind of class a schema refers to by name is looked up. Query and
|
|
13
13
|
# mutation fields resolve to GraphQL::Schema::Resolver / Mutation classes,
|
|
14
14
|
# builders compose onto a relation, validators check arguments: distinct
|
|
15
|
-
# contracts, so distinct namespaces (app/graphql/
|
|
15
|
+
# contracts, so distinct namespaces (app/graphql/resolvers, .../mutations, ...).
|
|
16
16
|
DEFAULT_NAMESPACES = {
|
|
17
|
-
queries: ['
|
|
17
|
+
queries: ['Resolvers'],
|
|
18
18
|
mutations: ['Mutations'],
|
|
19
19
|
builders: ['Builders'],
|
|
20
|
-
validators: ['Validators']
|
|
20
|
+
validators: ['Validators'],
|
|
21
|
+
events: ['Events'],
|
|
22
|
+
subscriptions: ['Subscriptions'],
|
|
23
|
+
analyzers: ['Analyzers']
|
|
21
24
|
}.freeze
|
|
22
25
|
|
|
23
26
|
# Every setting reset! forgets, so the next read falls back to its default.
|
|
@@ -25,19 +28,23 @@ module Lighthouse
|
|
|
25
28
|
sdl_folder resolver_namespaces namespaces cache_store transactional_mutations pagination_default_count pagination_max_count
|
|
26
29
|
schema_cache schema_cache_path watch_sdl persisted_queries persisted_queries_store persisted_queries_ttl
|
|
27
30
|
persisted_queries_manifest persisted_queries_only on_error directive_map authenticated_user policy_user authorizer
|
|
31
|
+
feature_checker feature_actor
|
|
32
|
+
convert_empty_strings_to_null
|
|
33
|
+
soft_deletes
|
|
34
|
+
queue_broadcasts broadcast_queue
|
|
28
35
|
].freeze
|
|
29
36
|
|
|
30
37
|
class << self
|
|
31
38
|
attr_writer :sdl_folder, :resolver_namespaces, :on_error, :authenticated_user, :policy_user, :authorizer, :cache_store,
|
|
32
|
-
:transactional_mutations, :schema_cache, :watch_sdl, :persisted_queries, :persisted_queries_store, :persisted_queries_only
|
|
39
|
+
:transactional_mutations, :schema_cache, :watch_sdl, :persisted_queries, :persisted_queries_store, :persisted_queries_only,
|
|
40
|
+
:queue_broadcasts,
|
|
41
|
+
:convert_empty_strings_to_null, :feature_checker, :feature_actor
|
|
33
42
|
|
|
34
43
|
def sdl_folder
|
|
35
44
|
@sdl_folder ||= (defined?(Rails) && Rails.respond_to?(:root) ? Rails.root.join('graphql').to_s : nil)
|
|
36
45
|
end
|
|
37
46
|
|
|
38
|
-
def directive_map
|
|
39
|
-
@directive_map ||= {}
|
|
40
|
-
end
|
|
47
|
+
def directive_map = @directive_map ||= {}
|
|
41
48
|
|
|
42
49
|
# Namespaces tried (in order) when resolving a class name from a directive
|
|
43
50
|
# argument, e.g. `@field(resolver: "ContactPerson")` or `@all(model: "X")`.
|
|
@@ -67,6 +74,15 @@ module Lighthouse
|
|
|
67
74
|
end
|
|
68
75
|
end
|
|
69
76
|
|
|
77
|
+
# --- Subscriptions (used by @broadcast) ----------------------------------
|
|
78
|
+
#
|
|
79
|
+
# queue_broadcasts perform @broadcast triggers in a Lighthouse::GraphQL::BroadcastJob
|
|
80
|
+
# (ActiveJob) instead of inline; a directive's shouldQueue overrides it
|
|
81
|
+
# broadcast_queue the job's queue name; nil: ActiveJob's default
|
|
82
|
+
attr_accessor :broadcast_queue
|
|
83
|
+
|
|
84
|
+
def queue_broadcasts = @queue_broadcasts || false
|
|
85
|
+
|
|
70
86
|
# --- Scale options: all off by default ----------------------------------
|
|
71
87
|
#
|
|
72
88
|
# schema_cache cache the built schema document in tmp/cache (SchemaCache)
|
|
@@ -84,6 +100,10 @@ module Lighthouse
|
|
|
84
100
|
def persisted_queries = @persisted_queries || false
|
|
85
101
|
def persisted_queries_only = @persisted_queries_only || false
|
|
86
102
|
|
|
103
|
+
# Make every field behave as if it carried @convertEmptyStringsToNull. Read
|
|
104
|
+
# when the schema is built, so set it in an initializer.
|
|
105
|
+
def convert_empty_strings_to_null = @convert_empty_strings_to_null || false
|
|
106
|
+
|
|
87
107
|
def persisted_queries_store
|
|
88
108
|
@persisted_queries_store || cache_store
|
|
89
109
|
end
|
|
@@ -102,7 +122,7 @@ module Lighthouse
|
|
|
102
122
|
# re-raising in test (so failures surface) and logging otherwise. Override
|
|
103
123
|
# via `Lighthouse.configure { |c| c.on_error = ->(e, ctx) { ... } }`.
|
|
104
124
|
def on_error
|
|
105
|
-
@on_error ||=
|
|
125
|
+
@on_error ||= Defaults.on_error
|
|
106
126
|
end
|
|
107
127
|
|
|
108
128
|
def handle_error(error, context = nil)
|
|
@@ -131,9 +151,28 @@ module Lighthouse
|
|
|
131
151
|
# `record` may be a model class (@canModel). `args` carries the @can*
|
|
132
152
|
# `args:` values and, with `injectArgs: true`, the field's arguments first.
|
|
133
153
|
# Defaults to Pundit: `Pundit.policy!(user, record).<ability>?(*args)`.
|
|
134
|
-
def authorizer
|
|
135
|
-
|
|
136
|
-
|
|
154
|
+
def authorizer = @authorizer ||= Defaults.authorizer
|
|
155
|
+
|
|
156
|
+
# --- Feature flags (used by @feature) ------------------------------------
|
|
157
|
+
|
|
158
|
+
# Is a feature on? `->(name, actor, ctx) { true/false }`, with the feature's
|
|
159
|
+
# name as written in the SDL. Defaults to Flipper: `Flipper.enabled?(name.to_sym, actor)`.
|
|
160
|
+
def feature_checker = @feature_checker ||= Lighthouse::GraphQL::FeatureDirective.default_checker
|
|
161
|
+
|
|
162
|
+
# The actor features are checked for: `->(ctx) { actor }` (a Flipper actor
|
|
163
|
+
# answers `flipper_id`; nil checks the feature globally). Defaults to the
|
|
164
|
+
# authenticated user.
|
|
165
|
+
def feature_actor = @feature_actor ||= ->(ctx) { authenticated_user.call(ctx) }
|
|
166
|
+
|
|
167
|
+
# --- Soft deletes (used by @softDeletes, @trashed, @restore, @forceDelete) ---
|
|
168
|
+
|
|
169
|
+
# Teaches Lighthouse what "trashed" means. Any object answering
|
|
170
|
+
# `soft_deletes?(model)`, `only_trashed(relation)`, `with_trashed(relation)`,
|
|
171
|
+
# `without_trashed(relation)`, `restore(record)` and `force_delete(record)`.
|
|
172
|
+
# Defaults to detecting paranoia, then discard, then a `deleted_at` column
|
|
173
|
+
# per model (Support::SoftDeletes::Auto); `Support::SoftDeletes::Column.new(:archived_at)`
|
|
174
|
+
# is one for another column.
|
|
175
|
+
attr_accessor :soft_deletes
|
|
137
176
|
|
|
138
177
|
def configure
|
|
139
178
|
yield(self) if block_given?
|
|
@@ -146,10 +185,13 @@ module Lighthouse
|
|
|
146
185
|
def reset!
|
|
147
186
|
SETTINGS.each { |setting| instance_variable_set("@#{setting}", nil) }
|
|
148
187
|
end
|
|
188
|
+
end
|
|
149
189
|
|
|
150
|
-
|
|
190
|
+
# The callables the authorizer and on_error settings fall back to.
|
|
191
|
+
module Defaults
|
|
192
|
+
module_function
|
|
151
193
|
|
|
152
|
-
def
|
|
194
|
+
def authorizer
|
|
153
195
|
lambda do |user, record, ability, args = []|
|
|
154
196
|
raise 'Lighthouse: configure `Config.authorizer` to use the @can* directives (Pundit not detected).' unless defined?(::Pundit)
|
|
155
197
|
|
|
@@ -158,7 +200,7 @@ module Lighthouse
|
|
|
158
200
|
end
|
|
159
201
|
end
|
|
160
202
|
|
|
161
|
-
def
|
|
203
|
+
def on_error
|
|
162
204
|
lambda do |error, _context|
|
|
163
205
|
if defined?(Rails) && Rails.respond_to?(:env) && Rails.env.test?
|
|
164
206
|
raise error
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/concern'
|
|
4
|
+
require 'active_support/core_ext/class/attribute'
|
|
5
|
+
require 'json'
|
|
6
|
+
|
|
7
|
+
# A Rails controller concern that serves a Lighthouse schema: include it, name
|
|
8
|
+
# the schema, route to `execute`.
|
|
9
|
+
#
|
|
10
|
+
# class GraphqlController < ApplicationController
|
|
11
|
+
# include Lighthouse::GraphQL::Controller
|
|
12
|
+
#
|
|
13
|
+
# lighthouse_schema AppSchema # or 'AppSchema', or { AppSchema }, resolved per request
|
|
14
|
+
#
|
|
15
|
+
# private
|
|
16
|
+
#
|
|
17
|
+
# def graphql_context = { current_user: current_user }
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# `execute` dispatches StartRequest, reads the variables (a JSON String, a Hash
|
|
21
|
+
# or parameters), runs the operation through PersistedQueries.execute (or
|
|
22
|
+
# PersistedQueries.multiplex for a batch, a JSON array body), sets the
|
|
23
|
+
# Cache-Control header the selected fields' @cacheControl hints allow, renders
|
|
24
|
+
# the JSON and dispatches EndRequest. In development an error renders as JSON.
|
|
25
|
+
#
|
|
26
|
+
# Hooks, private so they are not actions: `graphql_context` (default:
|
|
27
|
+
# `{ current_user: current_user }` when the controller has a current_user, else
|
|
28
|
+
# `{}`), `graphql_schema` and `graphql_variables(param)`. A controller with an
|
|
29
|
+
# action of its own wraps it in `lighthouse_request { ... }` to get the request
|
|
30
|
+
# events; a nested call dispatches them once.
|
|
31
|
+
module Lighthouse::GraphQL::Controller
|
|
32
|
+
extend ActiveSupport::Concern
|
|
33
|
+
|
|
34
|
+
included do
|
|
35
|
+
class_attribute :lighthouse_schema_source, instance_writer: false
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class_methods do
|
|
39
|
+
# The schema `execute` runs: a schema class, its name (a String, constantized
|
|
40
|
+
# per request so code reloading works) or a block (evaluated in the
|
|
41
|
+
# controller, per request).
|
|
42
|
+
def lighthouse_schema(schema = nil, &block)
|
|
43
|
+
self.lighthouse_schema_source = schema || block
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def execute
|
|
48
|
+
lighthouse_request do
|
|
49
|
+
batch = params[:_json]
|
|
50
|
+
if batch.is_a?(Array)
|
|
51
|
+
render json: lighthouse_batch(batch)
|
|
52
|
+
else
|
|
53
|
+
result = lighthouse_operation
|
|
54
|
+
Lighthouse::GraphQL::CacheControl.apply(response, result)
|
|
55
|
+
render json: result
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
rescue StandardError => e
|
|
59
|
+
raise e unless defined?(::Rails) && ::Rails.env.development?
|
|
60
|
+
|
|
61
|
+
lighthouse_development_error(e)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
# Dispatch StartRequest, run the block, dispatch EndRequest with the
|
|
67
|
+
# response. Only the outermost call dispatches.
|
|
68
|
+
def lighthouse_request
|
|
69
|
+
return yield if @lighthouse_request_started
|
|
70
|
+
|
|
71
|
+
@lighthouse_request_started = true
|
|
72
|
+
Lighthouse::Events.dispatch(Lighthouse::Events::StartRequest.new(request))
|
|
73
|
+
result = yield
|
|
74
|
+
Lighthouse::Events.dispatch(Lighthouse::Events::EndRequest.new(response))
|
|
75
|
+
result
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The context of the operation: what @guard and the @can* directives read.
|
|
79
|
+
def graphql_context
|
|
80
|
+
respond_to?(:current_user, true) ? { current_user: current_user } : {}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def graphql_schema
|
|
84
|
+
source = lighthouse_schema_source
|
|
85
|
+
case source
|
|
86
|
+
when nil then raise Lighthouse::DefinitionError, "#{self.class}: name the schema with `lighthouse_schema AppSchema`"
|
|
87
|
+
when String, Symbol then source.to_s.constantize
|
|
88
|
+
when Proc then instance_exec(&source)
|
|
89
|
+
else source
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Variables arrive as a JSON string (GET or form posts) or as parameters.
|
|
94
|
+
def graphql_variables(param)
|
|
95
|
+
case param
|
|
96
|
+
when String then param.present? ? JSON.parse(param) || {} : {}
|
|
97
|
+
when Hash then param
|
|
98
|
+
when nil then {}
|
|
99
|
+
else
|
|
100
|
+
raise ArgumentError, "Unexpected parameter: #{param}" unless param.respond_to?(:to_unsafe_h)
|
|
101
|
+
|
|
102
|
+
param.to_unsafe_h
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def lighthouse_operation
|
|
107
|
+
Lighthouse::GraphQL::PersistedQueries.execute(
|
|
108
|
+
graphql_schema,
|
|
109
|
+
query: params[:query], extensions: params[:extensions],
|
|
110
|
+
variables: graphql_variables(params[:variables]), operation_name: params[:operationName], context: graphql_context
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Each operation gets its own context: `schema.multiplex`'s `context:` is the
|
|
115
|
+
# multiplex's, which the queries' resolvers never see.
|
|
116
|
+
def lighthouse_batch(operations)
|
|
117
|
+
operations = operations.map do |operation|
|
|
118
|
+
{ query: operation[:query], extensions: operation[:extensions], variables: graphql_variables(operation[:variables]),
|
|
119
|
+
operation_name: operation[:operationName], context: graphql_context }
|
|
120
|
+
end
|
|
121
|
+
Lighthouse::GraphQL::PersistedQueries.multiplex(graphql_schema, operations)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def lighthouse_development_error(error)
|
|
125
|
+
logger.error(error.message)
|
|
126
|
+
logger.error(error.backtrace.join("\n"))
|
|
127
|
+
|
|
128
|
+
render json: { errors: [{ message: error.message, backtrace: error.backtrace }], data: {} },
|
|
129
|
+
status: :internal_server_error
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
# @broadcast — on a mutation (or any field), trigger a subscription once the
|
|
6
|
+
# field resolved successfully: its result is the root the subscribers'
|
|
7
|
+
# updates are resolved from, each passing through the subscription's
|
|
8
|
+
# `filter?`. A nil result or a GraphQL::ExecutionError (a failed mutation)
|
|
9
|
+
# broadcasts nothing; repeat the directive to trigger several subscriptions.
|
|
10
|
+
# See Lighthouse::GraphQL::Broadcasting for how it is triggered, and queued.
|
|
11
|
+
class Lighthouse::GraphQL::DirectiveResolvers::BroadcastDirectiveResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
|
|
12
|
+
def self.directive_name = 'broadcast'
|
|
13
|
+
|
|
14
|
+
def self.definition
|
|
15
|
+
'directive @broadcast(subscription: String!, shouldQueue: Boolean) repeatable on FIELD_DEFINITION'
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def define_resolver(target_type_class)
|
|
19
|
+
subscription = directive_arg('subscription')
|
|
20
|
+
should_queue = directive_arg('shouldQueue')
|
|
21
|
+
inner = capture_inner(target_type_class)
|
|
22
|
+
|
|
23
|
+
install(target_type_class, lambda do |obj, args, ctx|
|
|
24
|
+
result = inner.call(obj, args, ctx)
|
|
25
|
+
unless result.nil? || result.is_a?(::GraphQL::ExecutionError)
|
|
26
|
+
Lighthouse::GraphQL::Broadcasting.broadcast(ctx.schema, subscription, result, ctx, should_queue: should_queue)
|
|
27
|
+
end
|
|
28
|
+
result
|
|
29
|
+
end)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -4,14 +4,17 @@ require_relative 'base'
|
|
|
4
4
|
|
|
5
5
|
# Resolves a Query or Mutation field that has no resolving directive by
|
|
6
6
|
# convention, as PHP Lighthouse does: `Query.latestPosts` resolves to
|
|
7
|
-
# `
|
|
8
|
-
#
|
|
7
|
+
# `Resolvers::LatestPosts` (a GraphQL::Schema::Resolver, by graphql-ruby's
|
|
8
|
+
# convention a Resolvers::BaseResolver), `Mutation.publishPost` to
|
|
9
|
+
# `Mutations::PublishPost` (a GraphQL::Schema::Mutation), looked up in
|
|
9
10
|
# Config.namespaces[:queries] / [:mutations] at call time so code reloading
|
|
10
11
|
# keeps working. Not a directive: SchemaGenerator installs it.
|
|
11
12
|
class Lighthouse::GraphQL::DirectiveResolvers::ConventionResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
|
|
12
|
-
|
|
13
|
+
# `base`: the class the schema's base_types require the class to inherit from.
|
|
14
|
+
def initialize(field_def, type_def, kind, base = nil)
|
|
13
15
|
super(field_def, type_def, nil)
|
|
14
16
|
@kind = kind
|
|
17
|
+
@base = base
|
|
15
18
|
end
|
|
16
19
|
|
|
17
20
|
def define_resolver(target_type_class)
|
|
@@ -31,6 +34,12 @@ class Lighthouse::GraphQL::DirectiveResolvers::ConventionResolver < Lighthouse::
|
|
|
31
34
|
ROOT_KINDS[type.respond_to?(:graphql_name) ? type.graphql_name : type.to_s]
|
|
32
35
|
end
|
|
33
36
|
|
|
37
|
+
# The class a root field resolves to, without its namespace: `LatestPosts`
|
|
38
|
+
# for `latestPosts`.
|
|
39
|
+
def self.class_name_for(field_def)
|
|
40
|
+
field_def.name.to_s.camelize
|
|
41
|
+
end
|
|
42
|
+
|
|
34
43
|
private
|
|
35
44
|
|
|
36
45
|
def resolve_by_convention(inner, obj, args, ctx)
|
|
@@ -43,6 +52,7 @@ class Lighthouse::GraphQL::DirectiveResolvers::ConventionResolver < Lighthouse::
|
|
|
43
52
|
"#{klass} resolves #{location}, so it must inherit from GraphQL::Schema::Resolver " \
|
|
44
53
|
'(or GraphQL::Schema::Mutation).'
|
|
45
54
|
end
|
|
55
|
+
Lighthouse::GraphQL::ResolverBases.check!(klass, @base, location)
|
|
46
56
|
|
|
47
57
|
Lighthouse::GraphQL::ResolverDispatch.call(klass, @field_def, obj, args, ctx)
|
|
48
58
|
end
|
|
@@ -61,7 +71,7 @@ class Lighthouse::GraphQL::DirectiveResolvers::ConventionResolver < Lighthouse::
|
|
|
61
71
|
end
|
|
62
72
|
|
|
63
73
|
def class_name
|
|
64
|
-
@class_name ||= @field_def
|
|
74
|
+
@class_name ||= self.class.class_name_for(@field_def)
|
|
65
75
|
end
|
|
66
76
|
|
|
67
77
|
def location
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
# @convertEmptyStringsToNull — replace incoming empty strings with null, as
|
|
6
|
+
# PHP Lighthouse's directive of the same name does.
|
|
7
|
+
#
|
|
8
|
+
# type Mutation {
|
|
9
|
+
# createPost(title: String @convertEmptyStringsToNull): Post! # this argument
|
|
10
|
+
# createUser(input: UserInput!): User! @convertEmptyStringsToNull # every input of the field
|
|
11
|
+
# }
|
|
12
|
+
# input UserInput { name: String @convertEmptyStringsToNull, bio: String }
|
|
13
|
+
#
|
|
14
|
+
# On a field it applies to all the field's inputs, nested input objects and
|
|
15
|
+
# lists included, but leaves a non-null input (`String!`) alone: only placing
|
|
16
|
+
# the directive on that argument or input field itself forces the conversion.
|
|
17
|
+
# On an input field it applies wherever the input type is used. Setting
|
|
18
|
+
# Config.convert_empty_strings_to_null makes every field behave as if it
|
|
19
|
+
# carried the directive.
|
|
20
|
+
#
|
|
21
|
+
# The conversion is a wrapper around the field's resolver (outermost, order
|
|
22
|
+
# 6), so every kind of resolver — directives, @field, Resolvers::/Mutations::
|
|
23
|
+
# classes, the default reader — and @validator see the converted arguments,
|
|
24
|
+
# and other fields sharing an input type are unaffected. SchemaGenerator
|
|
25
|
+
# installs it too, without the directive on the field, when an argument or
|
|
26
|
+
# input type it takes carries the directive, or the setting is on.
|
|
27
|
+
class Lighthouse::GraphQL::DirectiveResolvers::ConvertEmptyStringsToNullDirectiveResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
|
|
28
|
+
NAME = 'convertEmptyStringsToNull'
|
|
29
|
+
|
|
30
|
+
def self.directive_name = NAME
|
|
31
|
+
|
|
32
|
+
def self.definition
|
|
33
|
+
<<~GRAPHQL.chomp
|
|
34
|
+
"""
|
|
35
|
+
Replaces incoming empty strings `""` with `null`.
|
|
36
|
+
|
|
37
|
+
When used upon fields, empty strings for non-nullable inputs will pass unchanged.
|
|
38
|
+
Only explicitly placing this on non-nullable inputs will force the conversion.
|
|
39
|
+
"""
|
|
40
|
+
directive @convertEmptyStringsToNull on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION | FIELD_DEFINITION
|
|
41
|
+
GRAPHQL
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Does a field without the directive still convert? Yes when the setting is
|
|
45
|
+
# on, or when an argument or input type it takes carries the directive.
|
|
46
|
+
def self.implicit?(field_def)
|
|
47
|
+
return false if field_def.arguments.empty?
|
|
48
|
+
|
|
49
|
+
Lighthouse::Config.convert_empty_strings_to_null || carried?(field_def.arguments)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.explicit?(argument)
|
|
53
|
+
(argument.ast_node&.directives || []).any? { |d| d.name == NAME }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Does any argument, at any depth of input types, carry the directive?
|
|
57
|
+
def self.carried?(argument_defs, seen = Set.new)
|
|
58
|
+
argument_defs.each_value.any? do |argument|
|
|
59
|
+
type = argument.type.unwrap
|
|
60
|
+
explicit?(argument) || (type.kind.input_object? && seen.add?(type.graphql_name) && carried?(type.arguments, seen))
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def initialize(field_def, type_def, directive_node, opts = {})
|
|
65
|
+
super
|
|
66
|
+
@active = !directive_node.nil? || Lighthouse::Config.convert_empty_strings_to_null
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def define_resolver(target_type_class)
|
|
70
|
+
me = self
|
|
71
|
+
inner = capture_inner(target_type_class)
|
|
72
|
+
|
|
73
|
+
install(target_type_class, lambda do |obj, args, ctx|
|
|
74
|
+
inner.call(obj, me.convert_arguments(args), ctx)
|
|
75
|
+
end)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The field's arguments (a Hash keyed by snake_case symbols) with the empty
|
|
79
|
+
# strings replaced. Input objects that change are copied, never mutated.
|
|
80
|
+
def convert_arguments(args)
|
|
81
|
+
return args unless args.is_a?(Hash)
|
|
82
|
+
|
|
83
|
+
convert_hash(@field_def.arguments, args, @active)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def convert_hash(argument_defs, values, active)
|
|
89
|
+
by_keyword = Lighthouse::Support::Arguments.keyword_index(argument_defs)
|
|
90
|
+
values.to_h { |key, value| [key, convert_argument(by_keyword[key.to_sym], value, active)] }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def convert_argument(argument, value, active)
|
|
94
|
+
return value if argument.nil? || value.nil?
|
|
95
|
+
|
|
96
|
+
explicit = self.class.explicit?(argument)
|
|
97
|
+
convert_value(argument.type, value, active || explicit, explicit)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# `active`: the enclosing field or argument converts its nullable inputs.
|
|
101
|
+
# `force`: this argument itself carries the directive, so non-null ones too.
|
|
102
|
+
def convert_value(type, value, active, force)
|
|
103
|
+
nullable = !type.kind.non_null?
|
|
104
|
+
type = type.of_type unless nullable
|
|
105
|
+
case type.kind.name
|
|
106
|
+
when 'LIST' then convert_list(type.of_type, value, active, force)
|
|
107
|
+
when 'INPUT_OBJECT' then convert_input(type, value, active)
|
|
108
|
+
when 'SCALAR' then convert_scalar(value, force || (active && nullable))
|
|
109
|
+
else value
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def convert_scalar(value, convert)
|
|
114
|
+
convert && value == '' ? nil : value
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def convert_list(item_type, value, active, force)
|
|
118
|
+
return value unless value.is_a?(Array)
|
|
119
|
+
|
|
120
|
+
items = value.map { |item| convert_value(item_type, item, active, force) }
|
|
121
|
+
items.zip(value).all? { |new, old| new.equal?(old) } ? value : items
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def convert_input(type, value, active)
|
|
125
|
+
values = value.respond_to?(:to_kwargs) ? value.to_kwargs : value
|
|
126
|
+
return value unless values.is_a?(Hash)
|
|
127
|
+
|
|
128
|
+
converted = convert_hash(type.arguments, values, active)
|
|
129
|
+
return value if converted.all? { |key, new| new.equal?(values[key]) }
|
|
130
|
+
return converted unless value.respond_to?(:to_kwargs)
|
|
131
|
+
|
|
132
|
+
value.dup.tap { |copy| copy.instance_variable_set(:@ruby_style_hash, converted) }
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
# @event(dispatch: "OrderShipped") — dispatch an event once the field has
|
|
6
|
+
# resolved: the class is built with the resolved value and handed to
|
|
7
|
+
# Lighthouse::Events.dispatch. The value is returned unchanged; if the field
|
|
8
|
+
# raises, nothing is dispatched. Repeatable: each @event dispatches its own.
|
|
9
|
+
#
|
|
10
|
+
# The class is looked up in Config.namespaces[:events] (a fully-qualified
|
|
11
|
+
# name is used as-is); a name that is no class fails the schema build.
|
|
12
|
+
class Lighthouse::GraphQL::DirectiveResolvers::EventDirectiveResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
|
|
13
|
+
def self.directive_name = 'event'
|
|
14
|
+
def self.definition = 'directive @event(dispatch: String!) repeatable on FIELD_DEFINITION'
|
|
15
|
+
|
|
16
|
+
def define_resolver(target_type_class)
|
|
17
|
+
name = directive_arg('dispatch')
|
|
18
|
+
self.class.event_class(name, "#{@type_def.graphql_name}.#{@field_def.name}")
|
|
19
|
+
inner = capture_inner(target_type_class)
|
|
20
|
+
|
|
21
|
+
install(target_type_class, lambda do |obj, args, ctx|
|
|
22
|
+
result = inner.call(obj, args, ctx)
|
|
23
|
+
Lighthouse::Events.dispatch(Lighthouse::GraphQL::DirectiveResolvers::EventDirectiveResolver.event_class(name).new(result))
|
|
24
|
+
result
|
|
25
|
+
end)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The event class `name` names. Looked up on every dispatch, so a reloaded
|
|
29
|
+
# class is the one built.
|
|
30
|
+
def self.event_class(name, location = nil)
|
|
31
|
+
klass = Lighthouse::Support::ModelResolver.constantize(name, namespaces: Lighthouse::Config.namespaces[:events])
|
|
32
|
+
return klass if klass.respond_to?(:new)
|
|
33
|
+
|
|
34
|
+
raise Lighthouse::DefinitionError,
|
|
35
|
+
"@event on #{location}: no event class #{name} (looked in #{Lighthouse::Config.namespaces[:events].join(', ')})"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -181,34 +181,38 @@ module Lighthouse::GraphQL::DirectiveResolvers
|
|
|
181
181
|
end
|
|
182
182
|
end
|
|
183
183
|
|
|
184
|
-
# @delete
|
|
185
|
-
# (@whereKey, @eq, ...)
|
|
186
|
-
# for a list field, else the first or null. Refuses to run without a
|
|
187
|
-
# filter argument, which would
|
|
188
|
-
class
|
|
189
|
-
def self.directive_name = 'delete'
|
|
190
|
-
def self.definition = "directive @delete(#{ARGUMENTS}, scopes: [String!]) on #{LOCATIONS}"
|
|
191
|
-
|
|
184
|
+
# The shared shape of @delete, @forceDelete and @restore: act on each record
|
|
185
|
+
# the field's argument filters (@whereKey, @eq, ...) select, and return them:
|
|
186
|
+
# a list for a list field, else the first or null. Refuses to run without a
|
|
187
|
+
# filter argument, which would modify every row.
|
|
188
|
+
class ModifyModelsResolver < ModelMutationResolver
|
|
192
189
|
WOULD_MODIFY_ALL = 'Would modify all models, use an argument to filter.'
|
|
193
190
|
|
|
194
191
|
protected
|
|
195
192
|
|
|
196
|
-
# A record whose callbacks refuse the
|
|
197
|
-
# to the data, as PHP Lighthouse does; the others are still
|
|
193
|
+
# A record whose callbacks refuse the action is reported as an error next
|
|
194
|
+
# to the data, as PHP Lighthouse does; the others are still modified.
|
|
198
195
|
def mutate(model, _obj, args, ctx)
|
|
199
196
|
args = Lighthouse::Support::Arguments.shallow(args)
|
|
200
197
|
raise ::GraphQL::ExecutionError, WOULD_MODIFY_ALL unless filtered?(args)
|
|
201
198
|
|
|
202
|
-
|
|
203
|
-
records = apply_where_filters(relation, args).to_a
|
|
199
|
+
records = apply_where_filters(records_of(model), args).to_a
|
|
204
200
|
records.each do |record|
|
|
205
|
-
next if record
|
|
201
|
+
next if modify(record)
|
|
206
202
|
|
|
207
203
|
ctx.add_error(::GraphQL::ExecutionError.new("Could not modify model #{model.name} with ID #{record.id}."))
|
|
208
204
|
end
|
|
209
205
|
@field_def.type.list? ? records : records.first
|
|
210
206
|
end
|
|
211
207
|
|
|
208
|
+
# The relation the filters narrow down: the model's, narrowed by `scopes:`.
|
|
209
|
+
def records_of(model) = apply_scopes(model.all)
|
|
210
|
+
|
|
211
|
+
# Act on one record; false when its callbacks refused.
|
|
212
|
+
def modify(_record)
|
|
213
|
+
raise NotImplementedError
|
|
214
|
+
end
|
|
215
|
+
|
|
212
216
|
def filtered?(args)
|
|
213
217
|
registry = Lighthouse::DirectiveRegistry.default
|
|
214
218
|
(@field_def.ast_node.arguments || []).any? do |argument|
|
|
@@ -216,4 +220,15 @@ module Lighthouse::GraphQL::DirectiveResolvers
|
|
|
216
220
|
end
|
|
217
221
|
end
|
|
218
222
|
end
|
|
223
|
+
|
|
224
|
+
# @delete — destroy the models the field's argument filters select
|
|
225
|
+
# (@whereKey, @eq, ...), running their callbacks, and return them.
|
|
226
|
+
class DeleteDirectiveResolver < ModifyModelsResolver
|
|
227
|
+
def self.directive_name = 'delete'
|
|
228
|
+
def self.definition = "directive @delete(#{ARGUMENTS}, scopes: [String!]) on #{LOCATIONS}"
|
|
229
|
+
|
|
230
|
+
protected
|
|
231
|
+
|
|
232
|
+
def modify(record) = record.destroy
|
|
233
|
+
end
|
|
219
234
|
end
|