lighthouse-graphql 0.2.0 → 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 +77 -1
- data/README.md +16 -1
- 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/motivation.md +184 -0
- data/docs/mutations.md +15 -0
- data/docs/resolvers.md +13 -1
- data/docs/subscriptions.md +423 -0
- data/lib/generators/lighthouse/builder_generator.rb +1 -1
- 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/directives.rbs +158 -0
- data/sig/lighthouse/events.rbs +122 -0
- data/sig/lighthouse/graphql.rbs +111 -0
- data/sig/lighthouse.rbs +193 -0
- metadata +29 -2
|
@@ -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,158 @@
|
|
|
1
|
+
# The extension points for custom directives: the contracts a directive class
|
|
2
|
+
# includes, the registry it is registered in, and the base classes it can
|
|
3
|
+
# subclass. See docs/custom-directives.md.
|
|
4
|
+
module Lighthouse
|
|
5
|
+
# What the registry asks a self-describing directive class: its SDL name
|
|
6
|
+
# (without the @) and its `directive @name(...) on ...` definition.
|
|
7
|
+
interface _DirectiveClass
|
|
8
|
+
def directive_name: () -> String
|
|
9
|
+
def definition: () -> String?
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Marker modules: the registry detects a class's capabilities by which of
|
|
13
|
+
# them it includes. Only FieldResolver and ArgBuilder change how a class is
|
|
14
|
+
# registered; the others document intent.
|
|
15
|
+
module Contracts
|
|
16
|
+
module Directive
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Its instances, built with (field_def, type_def, directive_node, opts),
|
|
20
|
+
# install the field's resolver in `define_resolver(target_type)`.
|
|
21
|
+
module FieldResolver
|
|
22
|
+
include Directive
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Its instances, built with (arg_node, directive_node), answer
|
|
26
|
+
# `handle_builder(relation, value)` with the constrained relation.
|
|
27
|
+
module ArgBuilder
|
|
28
|
+
include Directive
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
module FieldMiddleware
|
|
32
|
+
include Directive
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
module FieldManipulator
|
|
36
|
+
include Directive
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
module ArgManipulator
|
|
40
|
+
include Directive
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
class DirectiveRegistry
|
|
45
|
+
RESOLUTION_ORDER: Hash[String, Integer]
|
|
46
|
+
NAMED_RESOLVERS: Hash[String, Symbol]
|
|
47
|
+
SELF_DESCRIBING_RESOLVERS: Array[Symbol]
|
|
48
|
+
ARG_BUILDERS: Array[Symbol]
|
|
49
|
+
PASSIVE_DEFINITIONS: Hash[String, String]
|
|
50
|
+
|
|
51
|
+
def self.default: () -> DirectiveRegistry
|
|
52
|
+
|
|
53
|
+
# register(MyDirective) when the class answers `directive_name`, else
|
|
54
|
+
# register('name', MyDirective).
|
|
55
|
+
def self.register: (Class | String | Symbol name_or_klass, ?Class? klass) -> DirectiveRegistry
|
|
56
|
+
|
|
57
|
+
def initialize: () -> void
|
|
58
|
+
def register: (Class | String | Symbol name_or_klass, ?Class? klass) -> self
|
|
59
|
+
def register_defaults: () -> self
|
|
60
|
+
def register_definition: (String | Symbol name, String definition) -> self
|
|
61
|
+
def register_type_definition: (String | Symbol name, String definition) -> self
|
|
62
|
+
def register_manipulator: (_SdlManipulator manipulator) -> self
|
|
63
|
+
|
|
64
|
+
def resolution_class: (String | Symbol name) -> Class?
|
|
65
|
+
def resolution_order: (String | Symbol name) -> Integer
|
|
66
|
+
def wrapper?: (String | Symbol name) -> bool
|
|
67
|
+
def resolution_names: () -> Array[String]
|
|
68
|
+
def arg_builder_class: (String | Symbol name) -> Class?
|
|
69
|
+
def arg_builder_names: () -> Array[String]
|
|
70
|
+
|
|
71
|
+
# { "name" => "directive @name(...) on ..." }
|
|
72
|
+
def directive_definitions: () -> Hash[String, String]
|
|
73
|
+
# { "Name" => "enum Name { ... }" }
|
|
74
|
+
def type_definitions: () -> Hash[String, String]
|
|
75
|
+
|
|
76
|
+
attr_reader sdl_manipulators: Array[_SdlManipulator]
|
|
77
|
+
def manipulate_sdl: (String sdl) -> String
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Writes the SDL an IDE needs next to the app's own: `bin/rails lighthouse:ide_helper`.
|
|
81
|
+
module IdeHelper
|
|
82
|
+
DIRECTIVES_FILE: String
|
|
83
|
+
PROGRAMMATIC_TYPES_FILE: String
|
|
84
|
+
|
|
85
|
+
# Writes both files into `output_dir` and returns their paths.
|
|
86
|
+
def self?.write: (output_dir: path, ?sdl_folder: path?, ?registry: DirectiveRegistry) -> Array[String]
|
|
87
|
+
def self?.directives_sdl: (?String sdl, ?registry: DirectiveRegistry) -> String
|
|
88
|
+
def self?.programmatic_types_sdl: (String sdl, ?registry: DirectiveRegistry) -> String
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
module GraphQL
|
|
92
|
+
module DirectiveResolvers
|
|
93
|
+
# The base of the built-in field directives, and of a custom one:
|
|
94
|
+
# subclass it and implement `define_resolver`.
|
|
95
|
+
class BaseDirectiveResolver
|
|
96
|
+
# graphql-ruby's field and type definitions, and the directive's AST node.
|
|
97
|
+
@field_def: untyped
|
|
98
|
+
@type_def: untyped
|
|
99
|
+
@directive_node: untyped
|
|
100
|
+
@opts: Hash[Symbol, untyped]
|
|
101
|
+
|
|
102
|
+
def initialize: (untyped field_def, untyped type_def, untyped directive_node, ?Hash[Symbol, untyped] opts) -> void
|
|
103
|
+
|
|
104
|
+
# Install the field's resolver on `target_type_class` (a graphql-ruby type class).
|
|
105
|
+
def define_resolver: (untyped target_type_class) -> untyped
|
|
106
|
+
|
|
107
|
+
def apply_where_filters: (relation relation, untyped args) -> relation
|
|
108
|
+
def apply_arg_builders: (relation relation, arguments args_h) -> relation
|
|
109
|
+
def extract_relation_name_from_directive: (untyped arg_node) -> String?
|
|
110
|
+
def constantize_model: (String? name) -> Class?
|
|
111
|
+
def apply_scopes: (relation relation) -> relation
|
|
112
|
+
|
|
113
|
+
# The literal value of an argument of this directive.
|
|
114
|
+
def directive_arg: (String name) -> untyped
|
|
115
|
+
def resolver_method_name: () -> String
|
|
116
|
+
# The field's resolver so far, for a directive that wraps it.
|
|
117
|
+
def capture_inner: (untyped target_type_class) -> untyped
|
|
118
|
+
def install: (untyped target_type_class, untyped callable) -> untyped
|
|
119
|
+
# The model the directive acts on; raises DefinitionError when there is none.
|
|
120
|
+
def model_class: () -> Class
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Applies the field's @builder directives, in SDL order, to its relation.
|
|
124
|
+
class BuilderApplier
|
|
125
|
+
DEFINITION: String
|
|
126
|
+
|
|
127
|
+
def self.apply: (relation relation, untyped field_def_ast_node, arguments args, context ctx, untyped resolver_instance) -> relation
|
|
128
|
+
def self.apply_directive: (relation relation, untyped directive_node, arguments args, context ctx, untyped resolver_instance) -> relation
|
|
129
|
+
def self.extract_directive_arg: (untyped directive_node, String name) -> untyped
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
module Directives
|
|
134
|
+
module Arguments
|
|
135
|
+
# The base of the argument filters (@eq, @in, @orderBy, ...), and of a
|
|
136
|
+
# custom one: subclass it and implement `handle_builder`.
|
|
137
|
+
class Base
|
|
138
|
+
include Contracts::ArgBuilder
|
|
139
|
+
|
|
140
|
+
# graphql-ruby's AST nodes for the argument and the directive on it.
|
|
141
|
+
@arg_node: untyped
|
|
142
|
+
@directive_node: untyped
|
|
143
|
+
|
|
144
|
+
def initialize: (untyped arg_node, untyped directive_node) -> void
|
|
145
|
+
|
|
146
|
+
# Constrain the relation by the value the client gave the argument.
|
|
147
|
+
def handle_builder: (relation relation, untyped value) -> relation
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
# The directive's `key:`, else the argument's name in snake_case.
|
|
152
|
+
def column: () -> String
|
|
153
|
+
def arel_column: (relation relation) -> untyped
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
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
|