lighthouse-graphql 0.2.1 → 0.3.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 +56 -1
- data/README.md +11 -0
- data/docs/caching.md +1 -1
- data/docs/configuration.md +68 -0
- data/docs/custom-directives.md +11 -1
- data/docs/directives.md +306 -0
- data/docs/events.md +201 -0
- data/docs/getting-started.md +38 -9
- data/docs/mutations.md +15 -0
- data/docs/resolvers.md +3 -1
- data/docs/subscriptions.md +423 -0
- data/lib/generators/lighthouse/install/templates/graphql_controller.rb.tt +7 -45
- data/lib/generators/lighthouse/install/templates/initializer.rb.tt +31 -3
- data/lib/generators/lighthouse/subscription_generator.rb +95 -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/config.rb +53 -12
- data/lib/lighthouse/controller.rb +131 -0
- data/lib/lighthouse/directive_resolvers/broadcast_resolver.rb +31 -0
- 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 +68 -0
- data/lib/lighthouse/directives/arguments/soft_deletes.rb +112 -0
- data/lib/lighthouse/directives/registry.rb +14 -6
- data/lib/lighthouse/directives/soft_deletes_directive.rb +123 -0
- data/lib/lighthouse/events/execution_trace.rb +51 -0
- data/lib/lighthouse/events/lifecycle.rb +146 -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 +22 -2
- 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 +20 -0
- data/lib/lighthouse/schema_cache.rb +12 -5
- data/lib/lighthouse/schema_generator.rb +27 -2
- data/lib/lighthouse/subscription.rb +120 -0
- 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 +33 -0
- data/sig/lighthouse.rbs +52 -1
- metadata +24 -1
|
@@ -43,7 +43,7 @@ module Lighthouse::GraphQL::SchemaGenerator
|
|
|
43
43
|
uses_cache_control = sdl_string.to_s.gsub(/directive\s+@cacheControl/, '').include?('@cacheControl')
|
|
44
44
|
# The manipulated document — from the schema cache when it is enabled
|
|
45
45
|
# and fresh (see SchemaCache), else built now.
|
|
46
|
-
document =
|
|
46
|
+
document = build_document(sdl_string)
|
|
47
47
|
impl = default_implementation
|
|
48
48
|
schema = from_document(document, impl, opts)
|
|
49
49
|
|
|
@@ -51,6 +51,7 @@ module Lighthouse::GraphQL::SchemaGenerator
|
|
|
51
51
|
Lighthouse::GraphQL::GeneratedResolvers.install_dispatcher(schema)
|
|
52
52
|
attach_schema(impl, schema)
|
|
53
53
|
post_process(schema, impl, **opts)
|
|
54
|
+
schema.trace_with(Lighthouse::Events::ExecutionTrace)
|
|
54
55
|
|
|
55
56
|
# @cacheControl hints become the response's Cache-Control header,
|
|
56
57
|
# computed per query from the fields it selects.
|
|
@@ -104,6 +105,18 @@ module Lighthouse::GraphQL::SchemaGenerator
|
|
|
104
105
|
Module.new { define_singleton_method(:parse) { |*| document } }
|
|
105
106
|
end
|
|
106
107
|
|
|
108
|
+
# The manipulated schema document for the app's SDL, from the schema cache
|
|
109
|
+
# when it is enabled and fresh. The build events fire around the phases:
|
|
110
|
+
# RegisterDirectiveNamespaces on every build (the cache key covers the
|
|
111
|
+
# registered directives), BuildSchemaString and ManipulateAST only when the
|
|
112
|
+
# document is built, i.e. on a cache miss.
|
|
113
|
+
def build_document(sdl, force: false)
|
|
114
|
+
Lighthouse::Events::SchemaBuild.register_directive_namespaces
|
|
115
|
+
Lighthouse::GraphQL::SchemaCache.document(sdl, force: force, finalize: Lighthouse::Events::SchemaBuild.method(:manipulate_ast)) do |source|
|
|
116
|
+
manipulate(Lighthouse::Events::SchemaBuild.schema_string(source))
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
107
120
|
# The SDL as the schema is built from it: directive definitions injected
|
|
108
121
|
# (so apps don't hand-declare Lighthouse's directives), then every
|
|
109
122
|
# registered manipulator run (pagination types, whereConditions inputs, ...).
|
|
@@ -112,7 +125,7 @@ module Lighthouse::GraphQL::SchemaGenerator
|
|
|
112
125
|
end
|
|
113
126
|
|
|
114
127
|
def post_process(schema, _impl, **_opts)
|
|
115
|
-
root_kinds =
|
|
128
|
+
root_kinds = root_kinds(schema)
|
|
116
129
|
|
|
117
130
|
# Inspect types and fields for directives and attach generated resolvers
|
|
118
131
|
schema.types.each_value do |type|
|
|
@@ -124,10 +137,22 @@ module Lighthouse::GraphQL::SchemaGenerator
|
|
|
124
137
|
type.fields.each_value { |field_def| process_field(type, field_def, root_kinds[type.graphql_name]) }
|
|
125
138
|
end
|
|
126
139
|
|
|
140
|
+
# @feature hides a field or type per query, through graphql-ruby's visibility.
|
|
141
|
+
Lighthouse::GraphQL::FeatureDirective.apply(schema)
|
|
142
|
+
|
|
143
|
+
# Every @broadcast must name a field of the Subscription type.
|
|
144
|
+
Lighthouse::GraphQL::Broadcasting.validate!(schema)
|
|
145
|
+
|
|
127
146
|
# return schema for convenience
|
|
128
147
|
schema
|
|
129
148
|
end
|
|
130
149
|
|
|
150
|
+
# { type name => :queries | :mutations | :subscriptions } for the schema's root types.
|
|
151
|
+
def root_kinds(schema)
|
|
152
|
+
{ schema.query => :queries, schema.mutation => :mutations, schema.subscription => :subscriptions }
|
|
153
|
+
.reject { |type, _| type.nil? }.transform_keys(&:graphql_name)
|
|
154
|
+
end
|
|
155
|
+
|
|
131
156
|
# A field's generated resolvers, then the runtime hooks that make
|
|
132
157
|
# graphql-ruby reach them.
|
|
133
158
|
def process_field(type, field_def, root_kind)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/concern'
|
|
4
|
+
require 'active_support/core_ext/class/attribute'
|
|
5
|
+
|
|
6
|
+
# PHP Lighthouse's subscription model, as a concern a plain graphql-ruby
|
|
7
|
+
# subscription includes. The class is written as graphql-ruby's guide has it,
|
|
8
|
+
# and gains the hooks of PHP Lighthouse's GraphQLSubscription, as
|
|
9
|
+
# `include Lighthouse::GraphQL::Controller` gives a controller its `execute`:
|
|
10
|
+
#
|
|
11
|
+
# class Subscriptions::PostUpdated < GraphQL::Schema::Subscription
|
|
12
|
+
# include Lighthouse::GraphQL::Subscription
|
|
13
|
+
#
|
|
14
|
+
# argument :author_id, ID, required: false
|
|
15
|
+
#
|
|
16
|
+
# def filter?(post, author_id: nil) = author_id.nil? || post.author_id.to_s == author_id
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# PHP Lighthouse here graphql-ruby
|
|
20
|
+
# authorize(Subscriber, Request) authorize?(**args) authorized? (initial request only)
|
|
21
|
+
# filter(Subscriber, $root) filter?(root, **args) update returns NO_UPDATE when false
|
|
22
|
+
# resolve($root, $args, ...) resolve_payload(root, **args) update's return value
|
|
23
|
+
# encodeTopic / decodeTopic self.topic_for(arguments:, field:, scope:)
|
|
24
|
+
#
|
|
25
|
+
# `object`, `context` and `field` are available in every hook, and `context`
|
|
26
|
+
# is the subscriber's: graphql-ruby stores it with the subscription and
|
|
27
|
+
# replays it for each update, so `context[:current_user]` is the subscriber.
|
|
28
|
+
#
|
|
29
|
+
# Precedence: the concern sits between the class and GraphQL::Schema::Subscription,
|
|
30
|
+
# so a class that defines `update` or `authorized?` itself wins, and its
|
|
31
|
+
# `filter?`, `resolve_payload` or `authorize?` are not called unless it calls
|
|
32
|
+
# `super`. Everything else graphql-ruby offers (`subscribe`, `unsubscribe`,
|
|
33
|
+
# `subscription_scope`, `ready?`) is untouched.
|
|
34
|
+
#
|
|
35
|
+
# Unlike a plain subscription, every subscriber of the field shares one topic
|
|
36
|
+
# (the field name and its scope, not its arguments), which is PHP Lighthouse's
|
|
37
|
+
# model: a trigger reaches all of them and `filter?` decides who gets it, from
|
|
38
|
+
# the subscriber's own arguments. `topic_by :arguments` turns graphql-ruby's
|
|
39
|
+
# exact-argument matching back on.
|
|
40
|
+
module Lighthouse::GraphQL::Subscription
|
|
41
|
+
extend ActiveSupport::Concern
|
|
42
|
+
|
|
43
|
+
included do
|
|
44
|
+
unless self < ::GraphQL::Schema::Subscription
|
|
45
|
+
raise Lighthouse::DefinitionError,
|
|
46
|
+
"#{self} includes Lighthouse::GraphQL::Subscription, which is for subclasses of GraphQL::Schema::Subscription " \
|
|
47
|
+
'(class Subscriptions::PostUpdated < GraphQL::Schema::Subscription).'
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
class_attribute :lighthouse_topic_by, instance_accessor: false, instance_predicate: false, default: :field
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
class_methods do
|
|
54
|
+
# :field (the default) or :arguments. See `topic_for`. Inherited by subclasses.
|
|
55
|
+
def topic_by(mode = nil)
|
|
56
|
+
return lighthouse_topic_by unless mode
|
|
57
|
+
|
|
58
|
+
raise ArgumentError, "topic_by takes :field or :arguments, not #{mode.inspect}" unless %i[field arguments].include?(mode)
|
|
59
|
+
|
|
60
|
+
self.lighthouse_topic_by = mode
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The stream a subscription belongs to, and the one a trigger addresses.
|
|
64
|
+
# Only the scope and the field by default: every subscriber is updated, and
|
|
65
|
+
# `filter?` narrows it down. With `topic_by :arguments` it is
|
|
66
|
+
# graphql-ruby's: the trigger's arguments must equal the subscriber's.
|
|
67
|
+
def topic_for(arguments:, field:, scope:)
|
|
68
|
+
return super if topic_by == :arguments
|
|
69
|
+
|
|
70
|
+
::GraphQL::Subscriptions::Serialize.dump_recursive([scope, field.graphql_name])
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The arguments @broadcast triggers this subscription with, for the
|
|
74
|
+
# `root` a mutation returned. Matter only with `topic_by :arguments`,
|
|
75
|
+
# where they must equal the subscribers' arguments.
|
|
76
|
+
def trigger_arguments(_root, _context)
|
|
77
|
+
{}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The `scope:` @broadcast triggers this subscription with. Needed when the
|
|
81
|
+
# class declares `subscription_scope`.
|
|
82
|
+
def trigger_scope(_root, _context)
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# May this client subscribe? Return false (or raise a GraphQL::ExecutionError)
|
|
88
|
+
# to refuse. Called once, with the subscription request, not for each update.
|
|
89
|
+
def authorize?(**_args)
|
|
90
|
+
true
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Should this subscriber receive the broadcast of `root`? `args` are the
|
|
94
|
+
# arguments the client subscribed with; `context` is theirs.
|
|
95
|
+
def filter?(_root, **_args)
|
|
96
|
+
true
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# The value an update resolves the field with; the broadcast root by default.
|
|
100
|
+
def resolve_payload(root, **_args)
|
|
101
|
+
root
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def authorized?(**args)
|
|
105
|
+
return super if context.query.subscription_update? || authorize?(**args)
|
|
106
|
+
|
|
107
|
+
[false, Lighthouse::AuthorizationError.new('Unauthorized subscription request.')]
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def update(**args)
|
|
111
|
+
filter?(object, **args) ? resolve_payload(object, **args) : ::GraphQL::Schema::Subscription::NO_UPDATE
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# What a Subscription field with no class of its own resolves to: graphql-ruby's
|
|
116
|
+
# subscription with the concern, so every subscriber gets every broadcast
|
|
117
|
+
# unchanged.
|
|
118
|
+
Lighthouse::GraphQL::Subscription::Default = Class.new(GraphQL::Schema::Subscription) do
|
|
119
|
+
include Lighthouse::GraphQL::Subscription
|
|
120
|
+
end
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Soft deletes behind one seam, for @softDeletes / @trashed, @restore,
|
|
4
|
+
# @forceDelete and @withoutGlobalScopes. Rails has no built-in soft deletes, so
|
|
5
|
+
# the model's strategy is detected, in this order:
|
|
6
|
+
#
|
|
7
|
+
# - paranoia (`acts_as_paranoid`): a default scope hides trashed records.
|
|
8
|
+
# - discard (`include Discard::Model`): nothing is hidden unless the model
|
|
9
|
+
# adds `default_scope -> { kept }`.
|
|
10
|
+
# - a plain `deleted_at` column (Column), with no gem.
|
|
11
|
+
#
|
|
12
|
+
# `Config.soft_deletes` replaces the detection with an adapter of the app's
|
|
13
|
+
# own: any object answering `soft_deletes?(model)`, `only_trashed(relation)`,
|
|
14
|
+
# `with_trashed(relation)`, `without_trashed(relation)`, `restore(record)` and
|
|
15
|
+
# `force_delete(record)` (the last two answering whether they succeeded).
|
|
16
|
+
# `Column.new(:archived_at)` is a ready-made one for another column name.
|
|
17
|
+
module Lighthouse::Support::SoftDeletes
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# The adapter in use: Config.soft_deletes, else the detecting one.
|
|
21
|
+
def adapter
|
|
22
|
+
Lighthouse::Config.soft_deletes || (@default ||= Lighthouse::Support::SoftDeletes::Auto.new)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def supported?(model)
|
|
26
|
+
adapter.soft_deletes?(model)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# A client-facing error: the schema asks for soft deletes on a model without them.
|
|
30
|
+
def require_support!(model)
|
|
31
|
+
return if supported?(model)
|
|
32
|
+
|
|
33
|
+
raise ::GraphQL::ExecutionError,
|
|
34
|
+
"Model #{model.name} does not support soft deletes (no paranoia, discard or deleted_at column; see Config.soft_deletes)."
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def only_trashed(relation)
|
|
38
|
+
require_support!(relation.klass)
|
|
39
|
+
adapter.only_trashed(relation)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def with_trashed(relation)
|
|
43
|
+
require_support!(relation.klass)
|
|
44
|
+
adapter.with_trashed(relation)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def without_trashed(relation)
|
|
48
|
+
require_support!(relation.klass)
|
|
49
|
+
adapter.without_trashed(relation)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The relation for a `Trashed` enum value: ONLY, WITH or WITHOUT.
|
|
53
|
+
def filter(relation, trashed)
|
|
54
|
+
case trashed.to_s.upcase
|
|
55
|
+
when 'ONLY' then only_trashed(relation)
|
|
56
|
+
when 'WITH' then with_trashed(relation)
|
|
57
|
+
when 'WITHOUT' then without_trashed(relation)
|
|
58
|
+
else raise ::GraphQL::ExecutionError, "Unknown trashed value #{trashed.inspect}, expected ONLY, WITH or WITHOUT."
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def restore(record)
|
|
63
|
+
require_support!(record.class)
|
|
64
|
+
adapter.restore(record)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def force_delete(record)
|
|
68
|
+
require_support!(record.class)
|
|
69
|
+
adapter.force_delete(record)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# paranoia: `with_deleted` / `only_deleted` / `#restore` / `#really_destroy!`.
|
|
73
|
+
class Paranoia
|
|
74
|
+
def soft_deletes?(model)
|
|
75
|
+
return false unless defined?(::Paranoia)
|
|
76
|
+
|
|
77
|
+
model.respond_to?(:paranoid?) && model.paranoid?
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def only_trashed(relation) = relation.only_deleted
|
|
81
|
+
def with_trashed(relation) = relation.with_deleted
|
|
82
|
+
|
|
83
|
+
def without_trashed(relation)
|
|
84
|
+
model = relation.klass
|
|
85
|
+
relation.with_deleted.where(model.paranoia_column => model.paranoia_sentinel_value)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def restore(record) = record.restore
|
|
89
|
+
|
|
90
|
+
def force_delete(record) = record.really_destroy!
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# discard: `kept` / `discarded` scopes, `#undiscard`; `#destroy` really deletes.
|
|
94
|
+
class Discard
|
|
95
|
+
def soft_deletes?(model)
|
|
96
|
+
return false unless defined?(::Discard::Model)
|
|
97
|
+
|
|
98
|
+
model.include?(::Discard::Model)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def only_trashed(relation) = with_trashed(relation).discarded
|
|
102
|
+
def with_trashed(relation) = relation.unscope(where: relation.klass.discard_column)
|
|
103
|
+
def without_trashed(relation) = with_trashed(relation).kept
|
|
104
|
+
|
|
105
|
+
def restore(record) = record.undiscard
|
|
106
|
+
|
|
107
|
+
def force_delete(record) = record.destroy
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# A nullable timestamp column (`deleted_at` by default) and no gem. It marks
|
|
111
|
+
# trashed records with a value and may or may not be hidden by a default scope:
|
|
112
|
+
# every filter unscopes the column first, so both work. Restoring sets it to
|
|
113
|
+
# null (skipping callbacks and validations); force deleting is `#destroy`.
|
|
114
|
+
class Column
|
|
115
|
+
DEFAULT_COLUMN = 'deleted_at'
|
|
116
|
+
|
|
117
|
+
def initialize(column = DEFAULT_COLUMN)
|
|
118
|
+
@column = column.to_s
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def soft_deletes?(model)
|
|
122
|
+
model.respond_to?(:column_names) && model.table_exists? && model.column_names.include?(@column)
|
|
123
|
+
rescue ::ActiveRecord::ActiveRecordError
|
|
124
|
+
false
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def only_trashed(relation) = with_trashed(relation).where.not(@column => nil)
|
|
128
|
+
def with_trashed(relation) = relation.unscope(where: @column)
|
|
129
|
+
def without_trashed(relation) = with_trashed(relation).where(@column => nil)
|
|
130
|
+
|
|
131
|
+
def restore(record)
|
|
132
|
+
record.update_columns(@column => nil) # rubocop:disable Rails/SkipsModelValidations -- a trashed record may no longer validate.
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def force_delete(record) = record.destroy
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# The default adapter: asks each strategy, in order, whether the model uses it.
|
|
139
|
+
class Auto
|
|
140
|
+
def initialize(strategies = nil)
|
|
141
|
+
@strategies = strategies || [Paranoia.new, Discard.new, Column.new]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def soft_deletes?(model)
|
|
145
|
+
!strategy(model).nil?
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def only_trashed(relation) = strategy!(relation.klass).only_trashed(relation)
|
|
149
|
+
def with_trashed(relation) = strategy!(relation.klass).with_trashed(relation)
|
|
150
|
+
def without_trashed(relation) = strategy!(relation.klass).without_trashed(relation)
|
|
151
|
+
def restore(record) = strategy!(record.class).restore(record)
|
|
152
|
+
def force_delete(record) = strategy!(record.class).force_delete(record)
|
|
153
|
+
|
|
154
|
+
private
|
|
155
|
+
|
|
156
|
+
def strategy(model)
|
|
157
|
+
@strategies.find { |strategy| strategy.soft_deletes?(model) }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def strategy!(model)
|
|
161
|
+
strategy(model) || raise(::GraphQL::ExecutionError, "Model #{model.name} does not support soft deletes.")
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
@@ -18,6 +18,16 @@ namespace :lighthouse do
|
|
|
18
18
|
puts "Cleared #{Lighthouse::GraphQL::SchemaCache.directory}"
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
+
desc 'Build the schema, dispatch the ValidateSchema event and check graphql-ruby accepts it (SCHEMA=AppSchema)'
|
|
22
|
+
task validate_schema: :environment do
|
|
23
|
+
schema = Object.const_get(ENV.fetch('SCHEMA', 'AppSchema'))
|
|
24
|
+
Lighthouse::Events.dispatch(Lighthouse::Events::ValidateSchema.new(schema))
|
|
25
|
+
schema.to_definition # builds every type: unresolvable references and duplicate names raise
|
|
26
|
+
puts "#{schema} is valid."
|
|
27
|
+
rescue StandardError => e
|
|
28
|
+
abort("#{schema || 'Schema'} is invalid: #{e.message}")
|
|
29
|
+
end
|
|
30
|
+
|
|
21
31
|
namespace :persisted_queries do
|
|
22
32
|
desc 'Validate every operation of the persisted query manifest against the schema (SCHEMA=AppSchema)'
|
|
23
33
|
task verify: :environment do
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# The Rails controller concern that serves a schema. See docs/getting-started.md.
|
|
2
|
+
module Lighthouse
|
|
3
|
+
module GraphQL
|
|
4
|
+
module Controller
|
|
5
|
+
module ClassMethods
|
|
6
|
+
# The schema `execute` runs: a schema class, its name (constantized per
|
|
7
|
+
# request) or a block (evaluated in the controller, per request).
|
|
8
|
+
def lighthouse_schema: (?(Class | String | Symbol)? schema) ?{ () -> untyped } -> untyped
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# The action: dispatches StartRequest, runs the operation (or a batch),
|
|
12
|
+
# sets Cache-Control, renders JSON, dispatches EndRequest.
|
|
13
|
+
def execute: () -> untyped
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
# Dispatch StartRequest, yield, dispatch EndRequest; only the outermost call does.
|
|
18
|
+
def lighthouse_request: [T] () { () -> T } -> T
|
|
19
|
+
|
|
20
|
+
# The operation's context; { current_user: current_user } when the controller has one.
|
|
21
|
+
def graphql_context: () -> Hash[Symbol, untyped]
|
|
22
|
+
|
|
23
|
+
# The schema class `lighthouse_schema` names.
|
|
24
|
+
def graphql_schema: () -> untyped
|
|
25
|
+
|
|
26
|
+
# The `variables` parameter as a Hash: from a JSON String, a Hash or parameters.
|
|
27
|
+
def graphql_variables: (untyped param) -> Hash[String, untyped]
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# The events Lighthouse dispatches and the dispatcher an app listens through.
|
|
2
|
+
# See docs/events.md.
|
|
3
|
+
module Lighthouse
|
|
4
|
+
module Events
|
|
5
|
+
# Anything that responds to `call(event)`; what it returns means something
|
|
6
|
+
# to some events (BuildSchemaString, RegisterDirectiveNamespaces,
|
|
7
|
+
# BuildExtensionsResponse), and is ignored by the rest.
|
|
8
|
+
interface _Listener
|
|
9
|
+
def call: (untyped event) -> untyped
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Register a listener (a callable or a block) for an event class and its
|
|
13
|
+
# subclasses; answers the listener, for `forget`.
|
|
14
|
+
def self.listen: (Class event_class, ?_Listener? callable) ?{ (untyped event) -> untyped } -> _Listener
|
|
15
|
+
|
|
16
|
+
def self.forget: (_Listener listener) -> nil
|
|
17
|
+
def self.reset!: () -> nil
|
|
18
|
+
|
|
19
|
+
# Call the listeners of the event's class and ancestors in registration
|
|
20
|
+
# order; answers their non-nil return values.
|
|
21
|
+
def self.dispatch: (untyped event) -> Array[untyped]
|
|
22
|
+
|
|
23
|
+
# Whether dispatching an `event_class` event would reach a listener or an
|
|
24
|
+
# ActiveSupport::Notifications subscriber.
|
|
25
|
+
def self.listening?: (Class event_class) -> bool
|
|
26
|
+
|
|
27
|
+
# The ActiveSupport::Notifications name an event class is instrumented as.
|
|
28
|
+
def self.notification_name: (Class event_class) -> String
|
|
29
|
+
|
|
30
|
+
# The base of the events; a listener on it hears all of them.
|
|
31
|
+
class Event
|
|
32
|
+
attr_reader moment: Time
|
|
33
|
+
|
|
34
|
+
def initialize: () -> void
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class StartRequest < Event
|
|
38
|
+
attr_reader request: untyped
|
|
39
|
+
|
|
40
|
+
def initialize: (untyped request) -> void
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
class StartOperationOrOperations < Event
|
|
44
|
+
attr_reader operation_or_operations: Hash[Symbol, untyped] | Array[Hash[Symbol, untyped]]
|
|
45
|
+
|
|
46
|
+
def initialize: (Hash[Symbol, untyped] | Array[Hash[Symbol, untyped]] operation_or_operations) -> void
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# A listener may answer SDL (a String) to append to the user schema.
|
|
50
|
+
class BuildSchemaString < Event
|
|
51
|
+
attr_reader user_schema: String
|
|
52
|
+
|
|
53
|
+
def initialize: (String user_schema) -> void
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# A listener may answer a namespace (a Module or its name), or an Array of them.
|
|
57
|
+
class RegisterDirectiveNamespaces < Event
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
class ManipulateAST < Event
|
|
61
|
+
attr_accessor document_ast: untyped
|
|
62
|
+
|
|
63
|
+
def initialize: (untyped document_ast) -> void
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
class StartExecution < Event
|
|
67
|
+
attr_reader schema: untyped
|
|
68
|
+
attr_reader query: untyped
|
|
69
|
+
attr_reader variables: Hash[String, untyped]?
|
|
70
|
+
attr_reader operation_name: String?
|
|
71
|
+
attr_reader context: untyped
|
|
72
|
+
|
|
73
|
+
def initialize: (untyped schema, untyped query, Hash[String, untyped]? variables, String? operation_name, untyped context) -> void
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# A listener may answer an ExtensionsResponse, or an Array of them.
|
|
77
|
+
class BuildExtensionsResponse < Event
|
|
78
|
+
attr_reader result: Hash[String, untyped]
|
|
79
|
+
attr_reader query_complexity: Integer?
|
|
80
|
+
|
|
81
|
+
def initialize: (Hash[String, untyped] result, Integer? query_complexity) -> void
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
class ManipulateResult < Event
|
|
85
|
+
attr_accessor result: Hash[String, untyped]
|
|
86
|
+
|
|
87
|
+
def initialize: (Hash[String, untyped] result) -> void
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
class EndExecution < Event
|
|
91
|
+
attr_reader result: Hash[String, untyped]
|
|
92
|
+
|
|
93
|
+
def initialize: (Hash[String, untyped] result) -> void
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
class EndOperationOrOperations < Event
|
|
97
|
+
attr_reader result_or_results: untyped
|
|
98
|
+
|
|
99
|
+
def initialize: (untyped result_or_results) -> void
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
class EndRequest < Event
|
|
103
|
+
attr_reader response: untyped
|
|
104
|
+
|
|
105
|
+
def initialize: (untyped response) -> void
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
class ValidateSchema < Event
|
|
109
|
+
attr_reader schema: untyped
|
|
110
|
+
|
|
111
|
+
def initialize: (untyped schema) -> void
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# A key and content merged into the "extensions" of a result.
|
|
115
|
+
class ExtensionsResponse
|
|
116
|
+
attr_reader key: String | Symbol
|
|
117
|
+
attr_reader content: untyped
|
|
118
|
+
|
|
119
|
+
def initialize: (String | Symbol key, untyped content) -> void
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
data/sig/lighthouse/graphql.rbs
CHANGED
|
@@ -43,6 +43,10 @@ module Lighthouse
|
|
|
43
43
|
# `schema.execute` with the query the request names: its result, or a
|
|
44
44
|
# protocol error as a ready-to-render { "errors" => [...] } Hash.
|
|
45
45
|
def self?.execute: (schema schema, query: String?, ?extensions: untyped, **untyped options) -> untyped
|
|
46
|
+
# A batch: `schema.multiplex` with the query each operation (a Hash of
|
|
47
|
+
# query, extensions, variables, operation_name, context) names. One result
|
|
48
|
+
# per operation, in order; a protocol error is a ready-to-render Hash.
|
|
49
|
+
def self?.multiplex: (schema schema, Array[Hash[Symbol | String, untyped]] operations, **untyped options) -> Array[untyped]
|
|
46
50
|
# The query text to run for a request's `query` and `extensions`.
|
|
47
51
|
def self?.resolve: (String? query, untyped extensions) -> String?
|
|
48
52
|
def self?.enabled?: () -> bool
|
|
@@ -51,6 +55,35 @@ module Lighthouse
|
|
|
51
55
|
def self?.register: (String query) -> String
|
|
52
56
|
end
|
|
53
57
|
|
|
58
|
+
# A concern for a GraphQL::Schema::Subscription (graphql-ruby ships no RBS, so
|
|
59
|
+
# the class it is included in is not declared): PHP Lighthouse's hooks and
|
|
60
|
+
# topic. All optional. `object` is the broadcast root and `context` the
|
|
61
|
+
# subscriber's. Including it elsewhere raises Lighthouse::DefinitionError.
|
|
62
|
+
module Subscription
|
|
63
|
+
# May this client subscribe? Called with the subscription request only.
|
|
64
|
+
def authorize?: (**untyped args) -> boolish
|
|
65
|
+
# Should this subscriber receive the broadcast of `root`? `args` are the
|
|
66
|
+
# arguments it subscribed with.
|
|
67
|
+
def filter?: (untyped root, **untyped args) -> boolish
|
|
68
|
+
# The value an update resolves the field with.
|
|
69
|
+
def resolve_payload: (untyped root, **untyped args) -> untyped
|
|
70
|
+
|
|
71
|
+
# The class methods the concern adds to its includer.
|
|
72
|
+
module ClassMethods
|
|
73
|
+
# :field (default): every subscriber shares the field's topic and filter?
|
|
74
|
+
# decides; :arguments: graphql-ruby's, subscribers match the trigger's arguments.
|
|
75
|
+
def topic_by: (?(:field | :arguments)? mode) -> (:field | :arguments)
|
|
76
|
+
def topic_for: (arguments: untyped, field: untyped, scope: untyped) -> String
|
|
77
|
+
# What @broadcast triggers the subscription with, for the root a mutation returned.
|
|
78
|
+
def trigger_arguments: (untyped root, context context) -> Hash[Symbol | String, untyped]
|
|
79
|
+
def trigger_scope: (untyped root, context context) -> untyped
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# What a Subscription field with no class of its own resolves to.
|
|
83
|
+
class Default
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
54
87
|
# The Cache-Control header the selected fields' @cacheControl hints allow.
|
|
55
88
|
module CacheControl
|
|
56
89
|
DEFINITION: String
|
data/sig/lighthouse.rbs
CHANGED
|
@@ -51,6 +51,25 @@ module Lighthouse
|
|
|
51
51
|
def call: (untyped user, untyped record, String | Symbol ability, ?Array[untyped] args) -> boolish
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
+
# Config.feature_checker: is the feature `name` (as written in @feature) on
|
|
55
|
+
# for `actor`? The default asks Flipper.
|
|
56
|
+
interface _FeatureChecker
|
|
57
|
+
def call: (String name, untyped actor, context? context) -> boolish
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Config.soft_deletes: what "trashed" means for @softDeletes, @trashed,
|
|
61
|
+
# @restore, @forceDelete and @withoutGlobalScopes. The filters take a model's
|
|
62
|
+
# relation and answer the constrained one; `restore` and `force_delete` act
|
|
63
|
+
# on a record and answer whether they succeeded.
|
|
64
|
+
interface _SoftDeletes
|
|
65
|
+
def soft_deletes?: (Class model) -> boolish
|
|
66
|
+
def only_trashed: (relation relation) -> relation
|
|
67
|
+
def with_trashed: (relation relation) -> relation
|
|
68
|
+
def without_trashed: (relation relation) -> relation
|
|
69
|
+
def restore: (untyped record) -> boolish
|
|
70
|
+
def force_delete: (untyped record) -> boolish
|
|
71
|
+
end
|
|
72
|
+
|
|
54
73
|
# What DirectiveRegistry#register_manipulator takes: it transforms the SDL
|
|
55
74
|
# before the schema is built.
|
|
56
75
|
interface _SdlManipulator
|
|
@@ -70,7 +89,7 @@ module Lighthouse
|
|
|
70
89
|
def self.sdl_folder: () -> path?
|
|
71
90
|
def self.sdl_folder=: (path? folder) -> path?
|
|
72
91
|
|
|
73
|
-
# { queries:, mutations:, builders:, validators: } => [namespace, ...].
|
|
92
|
+
# { queries:, mutations:, subscriptions:, builders:, validators:, events: } => [namespace, ...].
|
|
74
93
|
# Assigning a partial hash overrides only the kinds it names.
|
|
75
94
|
def self.namespaces: () -> Hash[Symbol, Array[String]]
|
|
76
95
|
def self.namespaces=: (Hash[Symbol | String, String | Array[String]] overrides) -> Hash[Symbol | String, String | Array[String]]
|
|
@@ -110,6 +129,16 @@ module Lighthouse
|
|
|
110
129
|
def self.persisted_queries_only: () -> bool
|
|
111
130
|
def self.persisted_queries_only=: (bool? enabled) -> bool?
|
|
112
131
|
|
|
132
|
+
def self.convert_empty_strings_to_null: () -> bool
|
|
133
|
+
def self.convert_empty_strings_to_null=: (bool? enabled) -> bool?
|
|
134
|
+
|
|
135
|
+
# Whether @broadcast triggers in an ActiveJob (Lighthouse::GraphQL::BroadcastJob)
|
|
136
|
+
# unless the directive says shouldQueue; and the job's queue (nil: ActiveJob's default).
|
|
137
|
+
def self.queue_broadcasts: () -> bool
|
|
138
|
+
def self.queue_broadcasts=: (bool? enabled) -> bool?
|
|
139
|
+
def self.broadcast_queue: () -> (String | Symbol)?
|
|
140
|
+
def self.broadcast_queue=: ((String | Symbol)? queue) -> (String | Symbol)?
|
|
141
|
+
|
|
113
142
|
def self.on_error: () -> _ErrorHandler
|
|
114
143
|
def self.on_error=: (_ErrorHandler? handler) -> _ErrorHandler?
|
|
115
144
|
def self.handle_error: (StandardError error, ?context? context) -> void
|
|
@@ -120,6 +149,28 @@ module Lighthouse
|
|
|
120
149
|
def self.policy_user=: (_UserReader? reader) -> _UserReader?
|
|
121
150
|
def self.authorizer: () -> _Authorizer
|
|
122
151
|
def self.authorizer=: (_Authorizer? authorizer) -> _Authorizer?
|
|
152
|
+
|
|
153
|
+
def self.soft_deletes: () -> _SoftDeletes?
|
|
154
|
+
def self.soft_deletes=: (_SoftDeletes? adapter) -> _SoftDeletes?
|
|
155
|
+
|
|
156
|
+
def self.feature_checker: () -> _FeatureChecker
|
|
157
|
+
def self.feature_checker=: (_FeatureChecker? checker) -> _FeatureChecker?
|
|
158
|
+
def self.feature_actor: () -> _UserReader
|
|
159
|
+
def self.feature_actor=: (_UserReader? reader) -> _UserReader?
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
module Support
|
|
163
|
+
module SoftDeletes
|
|
164
|
+
# A nullable timestamp column marking trashed records, with no gem:
|
|
165
|
+
# `Config.soft_deletes = Support::SoftDeletes::Column.new(:archived_at)`.
|
|
166
|
+
class Column
|
|
167
|
+
include _SoftDeletes
|
|
168
|
+
|
|
169
|
+
DEFAULT_COLUMN: String
|
|
170
|
+
|
|
171
|
+
def initialize: (?String | Symbol column) -> void
|
|
172
|
+
end
|
|
173
|
+
end
|
|
123
174
|
end
|
|
124
175
|
|
|
125
176
|
# The schema refers to something that does not exist or breaks a contract.
|