lighthouse-graphql 0.1.1 → 0.2.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 +154 -1
- data/README.md +42 -2
- data/docs/authorization.md +116 -57
- data/docs/best-practices.md +16 -3
- data/docs/caching.md +161 -0
- data/docs/configuration.md +83 -4
- data/docs/custom-directives.md +5 -1
- data/docs/directives.md +117 -38
- data/docs/getting-started.md +84 -0
- data/docs/mutations.md +260 -0
- data/docs/performance.md +138 -0
- data/docs/relationships.md +80 -33
- data/docs/resolvers.md +179 -0
- data/docs/validation.md +132 -0
- data/lib/generators/lighthouse/builder_generator.rb +30 -0
- data/lib/generators/lighthouse/class_generator.rb +61 -0
- data/lib/generators/lighthouse/install/install_generator.rb +127 -0
- data/lib/generators/lighthouse/install/templates/graphql.config.yml.tt +8 -0
- data/lib/generators/lighthouse/install/templates/graphql_controller.rb.tt +57 -0
- data/lib/generators/lighthouse/install/templates/initializer.rb.tt +67 -0
- data/lib/generators/lighthouse/install/templates/schema.graphql.tt +19 -0
- data/lib/generators/lighthouse/install/templates/schema.rb.tt +10 -0
- data/lib/generators/lighthouse/mutation_generator.rb +30 -0
- data/lib/generators/lighthouse/query_generator.rb +29 -0
- data/lib/generators/lighthouse/validator_generator.rb +36 -0
- data/lib/lighthouse/cache_control.rb +126 -0
- data/lib/lighthouse/caching.rb +68 -0
- data/lib/lighthouse/config.rb +85 -19
- data/lib/lighthouse/contracts/arg_builder.rb +8 -12
- data/lib/lighthouse/contracts/arg_manipulator.rb +8 -12
- data/lib/lighthouse/contracts/directive.rb +10 -14
- data/lib/lighthouse/contracts/field_manipulator.rb +8 -12
- data/lib/lighthouse/contracts/field_middleware.rb +7 -11
- data/lib/lighthouse/contracts/field_resolver.rb +9 -13
- data/lib/lighthouse/dataloader_sources/association_loader.rb +28 -19
- data/lib/lighthouse/dataloader_sources/model_loader.rb +10 -14
- data/lib/lighthouse/dataloader_sources/relation_count_loader.rb +16 -0
- data/lib/lighthouse/dataloader_sources/relation_page_loader.rb +18 -0
- data/lib/lighthouse/directive_resolvers/aggregate_resolver.rb +57 -69
- data/lib/lighthouse/directive_resolvers/all_resolver.rb +26 -57
- data/lib/lighthouse/directive_resolvers/association_resolvers.rb +161 -0
- data/lib/lighthouse/directive_resolvers/auth_resolver.rb +41 -38
- data/lib/lighthouse/directive_resolvers/base.rb +142 -86
- data/lib/lighthouse/directive_resolvers/builder_applier.rb +58 -56
- data/lib/lighthouse/directive_resolvers/cache_resolvers.rb +133 -0
- data/lib/lighthouse/directive_resolvers/can_resolvers.rb +177 -0
- data/lib/lighthouse/directive_resolvers/convention_resolver.rb +70 -0
- data/lib/lighthouse/directive_resolvers/count_resolver.rb +42 -48
- data/lib/lighthouse/directive_resolvers/field_resolver.rb +27 -68
- data/lib/lighthouse/directive_resolvers/find_resolver.rb +42 -52
- data/lib/lighthouse/directive_resolvers/first_resolver.rb +39 -45
- data/lib/lighthouse/directive_resolvers/guard_resolver.rb +16 -22
- data/lib/lighthouse/directive_resolvers/method_resolver.rb +19 -25
- data/lib/lighthouse/directive_resolvers/model_mutation_resolver.rb +219 -0
- data/lib/lighthouse/directive_resolvers/paginate_resolver.rb +70 -128
- data/lib/lighthouse/directive_resolvers/rename_resolver.rb +18 -24
- data/lib/lighthouse/directive_resolvers/resolution.rb +31 -27
- data/lib/lighthouse/directive_resolvers/validator_resolver.rb +193 -0
- data/lib/lighthouse/directive_resolvers/where_conditions_applier.rb +164 -163
- data/lib/lighthouse/directives/arguments/base.rb +23 -31
- data/lib/lighthouse/directives/arguments/filters.rb +113 -93
- data/lib/lighthouse/directives/arguments/order_by.rb +41 -41
- data/lib/lighthouse/directives/pagination_info_types.rb +69 -0
- data/lib/lighthouse/directives/pagination_manipulator.rb +176 -0
- data/lib/lighthouse/directives/registry.rb +210 -141
- data/lib/lighthouse/directives/where_conditions_directive.rb +236 -200
- data/lib/lighthouse/errors.rb +27 -0
- data/lib/lighthouse/federation_directives.rb +53 -0
- data/lib/lighthouse/field_resolver_stack.rb +56 -0
- data/lib/lighthouse/generated_resolvers.rb +82 -0
- data/lib/lighthouse/graphql/version.rb +1 -1
- data/lib/lighthouse/graphql_ruby_patches.rb +18 -0
- data/lib/lighthouse/ide_helper.rb +98 -0
- data/lib/lighthouse/namespaces.rb +21 -0
- data/lib/lighthouse/nested_mutations/belongs_to_operations.rb +50 -0
- data/lib/lighthouse/nested_mutations/executor.rb +149 -0
- data/lib/lighthouse/nested_mutations/has_operations.rb +66 -0
- data/lib/lighthouse/nested_mutations/many_to_many_operations.rb +44 -0
- data/lib/lighthouse/nested_mutations/partitioner.rb +125 -0
- data/lib/lighthouse/nested_mutations.rb +210 -0
- data/lib/lighthouse/pagination.rb +221 -0
- data/lib/lighthouse/persisted_queries.rb +167 -0
- data/lib/lighthouse/railtie.rb +26 -0
- data/lib/lighthouse/rb_lighthouse.rb +50 -29
- data/lib/lighthouse/reference_resolver.rb +13 -15
- data/lib/lighthouse/relation_batch.rb +156 -0
- data/lib/lighthouse/resolver_dispatch.rb +26 -0
- data/lib/lighthouse/schema_cache.rb +120 -0
- data/lib/lighthouse/schema_factory.rb +27 -37
- data/lib/lighthouse/schema_generator.rb +196 -239
- data/lib/lighthouse/schema_implementation.rb +51 -48
- data/lib/lighthouse/sdl_loader.rb +17 -20
- data/lib/lighthouse/support/arguments.rb +119 -0
- data/lib/lighthouse/support/authorization.rb +18 -19
- data/lib/lighthouse/support/batching.rb +16 -0
- data/lib/lighthouse/support/directive_args.rb +26 -24
- data/lib/lighthouse/support/model_resolver.rb +56 -56
- data/lib/lighthouse/support/naming.rb +55 -59
- data/lib/lighthouse/tasks/lighthouse.rake +33 -0
- data/lib/lighthouse/where_conditions/operator_map.rb +25 -29
- metadata +51 -63
- data/lib/lighthouse/directive_resolvers/belongs_to_many_resolver.rb +0 -56
- data/lib/lighthouse/directive_resolvers/belongs_to_resolver.rb +0 -47
- data/lib/lighthouse/directive_resolvers/can_resolver.rb +0 -86
- data/lib/lighthouse/directive_resolvers/has_many_resolver.rb +0 -109
- data/lib/lighthouse/directive_resolvers/has_one_resolver.rb +0 -43
- data/lib/lighthouse/directives/relation_directive.rb +0 -332
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Nested mutations — PHP Lighthouse's nested arg resolvers, over ActiveRecord.
|
|
4
|
+
#
|
|
5
|
+
# An input field named after an association of the model being saved (or an
|
|
6
|
+
# argument with @create/@update/@upsert/@delete(relation:)) carries operations
|
|
7
|
+
# on that association instead of an attribute:
|
|
8
|
+
#
|
|
9
|
+
# belongsTo / morphTo connect, create, update, upsert, disconnect, delete
|
|
10
|
+
# hasOne / morphOne create, update, upsert, delete
|
|
11
|
+
# hasMany / morphMany create, update, upsert, connect, disconnect, delete
|
|
12
|
+
# belongsToMany / morphToMany sync, syncWithoutDetaching, create, update, upsert,
|
|
13
|
+
# delete, connect, disconnect
|
|
14
|
+
#
|
|
15
|
+
# Each record is saved once: attributes are assigned, belongsTo/morphTo
|
|
16
|
+
# operations set its foreign keys, it is saved, then the operations on
|
|
17
|
+
# associations that point back at it run. Errors are keyed by the full
|
|
18
|
+
# GraphQL path of the input (`input.author.create.name`).
|
|
19
|
+
#
|
|
20
|
+
# As in PHP Lighthouse, `update`, `upsert`, `delete` and `disconnect` find
|
|
21
|
+
# records by primary key in the whole table, not only among the parent's
|
|
22
|
+
# current children — an `update` re-parents the record it finds. Authorize
|
|
23
|
+
# the mutation field accordingly.
|
|
24
|
+
module Lighthouse::GraphQL::NestedMutations
|
|
25
|
+
MISSING_PRIMARY_KEY = 'Missing primary key for update.'
|
|
26
|
+
MISSING_IDENTIFYING_COLUMNS = 'All configured identifying columns must be present and non-null for upsert.'
|
|
27
|
+
|
|
28
|
+
# Argument directives that make an argument a nested operation, by the
|
|
29
|
+
# operation they stand for.
|
|
30
|
+
ARGUMENT_DIRECTIVES = {
|
|
31
|
+
'create' => 'create', 'createMany' => 'create',
|
|
32
|
+
'update' => 'update', 'updateMany' => 'update',
|
|
33
|
+
'upsert' => 'upsert', 'upsertMany' => 'upsert',
|
|
34
|
+
'delete' => 'delete'
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
37
|
+
OPERATIONS = {
|
|
38
|
+
belongs_to: %w[create connect update upsert disconnect delete],
|
|
39
|
+
morph_to: %w[connect disconnect delete],
|
|
40
|
+
has_one: %w[create update upsert delete],
|
|
41
|
+
has_many: %w[create update upsert connect disconnect delete],
|
|
42
|
+
many_to_many: %w[sync syncWithoutDetaching create update upsert delete connect disconnect]
|
|
43
|
+
}.freeze
|
|
44
|
+
|
|
45
|
+
# The PHP Lighthouse relation kind an association stands for, a key of OPERATIONS.
|
|
46
|
+
def self.kind(reflection)
|
|
47
|
+
case reflection.macro
|
|
48
|
+
when :belongs_to then reflection.polymorphic? ? :morph_to : :belongs_to
|
|
49
|
+
when :has_one then one_to_one_kind(reflection)
|
|
50
|
+
when :has_many then reflection.through_reflection? ? :many_to_many : :has_many
|
|
51
|
+
when :has_and_belongs_to_many then :many_to_many
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.one_to_one_kind(reflection)
|
|
56
|
+
return :has_one unless reflection.through_reflection?
|
|
57
|
+
|
|
58
|
+
raise Lighthouse::DefinitionError, "#{reflection.name}: nested mutations on has_one :through are not supported."
|
|
59
|
+
end
|
|
60
|
+
private_class_method :one_to_one_kind
|
|
61
|
+
|
|
62
|
+
# One level of input: the argument values (snake_case symbol keys), the
|
|
63
|
+
# GraphQL argument definitions they were given against, and their GraphQL path.
|
|
64
|
+
Input = Struct.new(:args, :argument_defs, :path)
|
|
65
|
+
|
|
66
|
+
# A nested operation's value, the argument it was given as, and — for an
|
|
67
|
+
# @upsert(identifyingColumns:) argument — the columns that identify records.
|
|
68
|
+
Operation = Struct.new(:value, :argument, :path, :identifying) do
|
|
69
|
+
def plain
|
|
70
|
+
Lighthouse::Support::Arguments.plain(value)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def input(value = self.value, path = self.path)
|
|
74
|
+
Input.new(Lighthouse::Support::Arguments.shallow(value), argument.type.unwrap.arguments, path)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# A list's items (a single object counts as a list of one).
|
|
78
|
+
def inputs
|
|
79
|
+
return [input] unless value.is_a?(Array)
|
|
80
|
+
|
|
81
|
+
value.each_with_index.map { |item, index| input(item, path + [index.to_s]) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def truthy?
|
|
85
|
+
value && value != false
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# A record's input split into attributes and association operations.
|
|
90
|
+
class Partition
|
|
91
|
+
attr_reader :attributes, :paths, :operations
|
|
92
|
+
|
|
93
|
+
def initialize(paths)
|
|
94
|
+
@attributes = {}
|
|
95
|
+
@paths = paths
|
|
96
|
+
@operations = {} # reflection => { "create" => Operation, ... }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def add(reflection, operations)
|
|
100
|
+
(@operations[reflection] ||= {}).merge!(operations)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def take_key(model)
|
|
104
|
+
key = ['id', model.primary_key].uniq.find { |k| @attributes.key?(k) }
|
|
105
|
+
key && [key, @attributes[key]]
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Sets a child's foreign key (and morph type) to its hasOne/hasMany owner.
|
|
110
|
+
HasLink = Struct.new(:owner, :reflection) do
|
|
111
|
+
def before_save(child)
|
|
112
|
+
child[reflection.foreign_key] = owner[reflection.active_record_primary_key]
|
|
113
|
+
child[reflection.type] = owner.class.polymorphic_name if reflection.type
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def after_save(_child); end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Attaches a saved child to a many-to-many owner, keeping existing links.
|
|
120
|
+
AttachLink = Struct.new(:pivot) do
|
|
121
|
+
def before_save(_child); end
|
|
122
|
+
|
|
123
|
+
def after_save(child)
|
|
124
|
+
pivot.attach(child.id)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# The join records of a belongsToMany / morphToMany association:
|
|
129
|
+
# has_many :through (whose join model may carry pivot columns) or
|
|
130
|
+
# has_and_belongs_to_many.
|
|
131
|
+
class Pivot
|
|
132
|
+
def initialize(owner, reflection)
|
|
133
|
+
@owner = owner
|
|
134
|
+
@reflection = reflection
|
|
135
|
+
@through = reflection.through_reflection?
|
|
136
|
+
return unless @through && !reflection.source_reflection.belongs_to?
|
|
137
|
+
|
|
138
|
+
raise Lighthouse::DefinitionError,
|
|
139
|
+
"#{owner.class}##{reflection.name} goes through #{reflection.through_reflection.name} to a " \
|
|
140
|
+
"#{reflection.source_reflection.macro}; nested mutations need a join model that belongs_to #{reflection.klass}."
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def ids
|
|
144
|
+
(@through ? joins.where(source_type_condition).pluck(source_key) : @owner.public_send(@reflection.name).pluck(@reflection.klass.primary_key))
|
|
145
|
+
.map(&:to_s)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Attach `id` unless attached already; with pivot values, set them either way.
|
|
149
|
+
def attach(id, pivot = {})
|
|
150
|
+
if @through
|
|
151
|
+
join = joins.find_or_initialize_by(source_type_condition.merge(source_key => id))
|
|
152
|
+
join.assign_attributes(pivot)
|
|
153
|
+
join.save! if join.new_record? || join.changed?
|
|
154
|
+
else
|
|
155
|
+
unless pivot.empty?
|
|
156
|
+
raise ::GraphQL::ExecutionError,
|
|
157
|
+
"Pivot values need a has_many :through association; #{@owner.class}##{@reflection.name} is " \
|
|
158
|
+
'has_and_belongs_to_many.'
|
|
159
|
+
end
|
|
160
|
+
@owner.public_send(@reflection.name) << @reflection.klass.find(id) unless ids.include?(id.to_s)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def detach(ids)
|
|
165
|
+
if @through
|
|
166
|
+
joins.where(source_type_condition.merge(source_key => ids)).destroy_all
|
|
167
|
+
else
|
|
168
|
+
@owner.public_send(@reflection.name).delete(*@reflection.klass.where(@reflection.klass.primary_key => ids).to_a)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def sync(pairs, detaching:)
|
|
173
|
+
wanted = pairs.map { |id, _| id.to_s }
|
|
174
|
+
stale = ids - wanted
|
|
175
|
+
detach(stale) if detaching && stale.any?
|
|
176
|
+
pairs.each { |id, pivot| attach(id, pivot) }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def reset
|
|
180
|
+
@owner.association(@reflection.name).reset
|
|
181
|
+
@owner.association(@reflection.through_reflection.name).reset if @through
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
private
|
|
185
|
+
|
|
186
|
+
def joins
|
|
187
|
+
@owner.public_send(@reflection.through_reflection.name)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def source_key
|
|
191
|
+
@reflection.source_reflection.foreign_key
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# A join model pointing at the related record polymorphically
|
|
195
|
+
# (`source_type:`) also stores its type.
|
|
196
|
+
def source_type_condition
|
|
197
|
+
source = @reflection.source_reflection
|
|
198
|
+
return {} unless source.polymorphic? && @reflection.options[:source_type]
|
|
199
|
+
|
|
200
|
+
{ source.foreign_type => @reflection.options[:source_type].to_s.constantize.polymorphic_name }
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# The executor and its parts open the namespace above compactly, so they load after it.
|
|
206
|
+
require_relative 'nested_mutations/partitioner'
|
|
207
|
+
require_relative 'nested_mutations/belongs_to_operations'
|
|
208
|
+
require_relative 'nested_mutations/has_operations'
|
|
209
|
+
require_relative 'nested_mutations/many_to_many_operations'
|
|
210
|
+
require_relative 'nested_mutations/executor'
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
|
|
5
|
+
# Runtime side of pagination — @paginate, and @hasMany / @belongsToMany /
|
|
6
|
+
# @morphMany / @morphToMany with `type:` — as PHP Lighthouse does it:
|
|
7
|
+
#
|
|
8
|
+
# PAGINATOR first/page → { paginatorInfo: PaginatorInfo!, data }
|
|
9
|
+
# SIMPLE first/page → { paginatorInfo: SimplePaginatorInfo!, data } (no total)
|
|
10
|
+
# CONNECTION first/after → { pageInfo: PageInfo!, edges { cursor node } }
|
|
11
|
+
#
|
|
12
|
+
# Cursors are base64 of the 1-based position, PHP Lighthouse's format. The
|
|
13
|
+
# schema side (types, arguments) is Directives::PaginationManipulator.
|
|
14
|
+
#
|
|
15
|
+
# A page reads its rows and total from a list: a RelationList queries one
|
|
16
|
+
# relation (or array), a BatchedList loads them through the Dataloader for
|
|
17
|
+
# every parent of a paginated relation at once.
|
|
18
|
+
module Lighthouse::GraphQL::Pagination
|
|
19
|
+
TYPES = %w[PAGINATOR SIMPLE CONNECTION].freeze
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# `relation`: a relation, an array or a list (BatchedList).
|
|
24
|
+
# `limits`: { default_count:, max_count: } — max_count may be :unlimited.
|
|
25
|
+
def paginate(relation, type, args, limits = {})
|
|
26
|
+
first = args[:first] || limits[:default_count] || Lighthouse::Config.pagination_default_count
|
|
27
|
+
validate!(first, limits.fetch(:max_count) { Lighthouse::Config.pagination_max_count })
|
|
28
|
+
|
|
29
|
+
list = relation.is_a?(List) ? relation : RelationList.new(relation)
|
|
30
|
+
case type
|
|
31
|
+
when 'SIMPLE' then simple(list, first, page(args))
|
|
32
|
+
when 'CONNECTION' then connection(list, first, offset_after(args[:after]))
|
|
33
|
+
else paginator(list, first, page(args))
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Where a page's rows and total come from.
|
|
38
|
+
module List; end
|
|
39
|
+
|
|
40
|
+
class RelationList
|
|
41
|
+
include List
|
|
42
|
+
|
|
43
|
+
def initialize(relation)
|
|
44
|
+
@relation = relation
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def window(offset, limit) = Lighthouse::GraphQL::Pagination.window(@relation, offset, limit)
|
|
48
|
+
def count = Lighthouse::GraphQL::Pagination.count(@relation)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# One parent's page of a relation batched with its siblings' (RelationBatch).
|
|
52
|
+
class BatchedList
|
|
53
|
+
include List
|
|
54
|
+
|
|
55
|
+
def initialize(batch, owner, dataloader)
|
|
56
|
+
@batch = batch
|
|
57
|
+
@owner = owner
|
|
58
|
+
@dataloader = dataloader
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def window(offset, limit)
|
|
62
|
+
return [] if limit.zero?
|
|
63
|
+
|
|
64
|
+
@dataloader.with(Lighthouse::GraphQL::DataloaderSources::RelationPageLoader, @batch, offset, limit).load(@owner)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def count
|
|
68
|
+
@dataloader.with(Lighthouse::GraphQL::DataloaderSources::RelationCountLoader, @batch).load(@owner)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def validate!(first, max_count)
|
|
73
|
+
raise ::GraphQL::ExecutionError, 'Pagination requires the `first` argument.' if first.nil?
|
|
74
|
+
raise ::GraphQL::ExecutionError, "Requested pagination amount must be non-negative, got #{first}." if first.negative?
|
|
75
|
+
return if max_count.nil? || max_count == :unlimited || first <= max_count
|
|
76
|
+
|
|
77
|
+
raise ::GraphQL::ExecutionError,
|
|
78
|
+
"Maximum number of #{max_count} requested items exceeded, got #{first}. Fetch smaller chunks."
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def page(args)
|
|
82
|
+
[args[:page] || 1, 1].max
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def paginator(list, per_page, page)
|
|
86
|
+
Paginator.new(list, per_page, page)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Fetches one extra row to tell whether there is a next page, instead of counting.
|
|
90
|
+
def simple(list, per_page, page)
|
|
91
|
+
rows = list.window((page - 1) * per_page, per_page + 1)
|
|
92
|
+
items = rows.first(per_page)
|
|
93
|
+
{
|
|
94
|
+
'paginatorInfo' => item_range(items, page, per_page).merge('hasMorePages' => rows.size > per_page),
|
|
95
|
+
'data' => items
|
|
96
|
+
}
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def connection(list, first, offset)
|
|
100
|
+
Connection.new(list, first, offset)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# A page whose total is only counted when the client selects
|
|
104
|
+
# paginatorInfo / pageInfo, so a query for `data` alone costs one query.
|
|
105
|
+
# Marshals with the total resolved, so @cache can store it.
|
|
106
|
+
class LazyPage
|
|
107
|
+
def initialize(list, items)
|
|
108
|
+
super()
|
|
109
|
+
@list = list
|
|
110
|
+
@items = items
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def marshal_dump
|
|
114
|
+
[info, @items]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def marshal_load((info, items))
|
|
118
|
+
@info = info
|
|
119
|
+
@items = items
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
def total
|
|
125
|
+
@total ||= @list.count
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
class Paginator < LazyPage
|
|
130
|
+
def initialize(list, per_page, page)
|
|
131
|
+
super(list, list.window((page - 1) * per_page, per_page))
|
|
132
|
+
@per_page = per_page
|
|
133
|
+
@page = page
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def data = @items
|
|
137
|
+
|
|
138
|
+
def paginatorInfo = info # rubocop:disable Naming/MethodName -- the GraphQL field's name, which the default resolver reads
|
|
139
|
+
|
|
140
|
+
def info
|
|
141
|
+
@info ||= begin
|
|
142
|
+
last_page = @per_page.zero? ? 0 : [(total.to_f / @per_page).ceil, 1].max
|
|
143
|
+
Lighthouse::GraphQL::Pagination.item_range(@items, @page, @per_page)
|
|
144
|
+
.merge('hasMorePages' => @page < last_page, 'lastPage' => last_page, 'total' => total)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
class Connection < LazyPage
|
|
150
|
+
def initialize(list, first, offset)
|
|
151
|
+
super(list, list.window(offset, first))
|
|
152
|
+
@first = first
|
|
153
|
+
@offset = offset
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def edges
|
|
157
|
+
@items.each_with_index.map { |node, i| { 'cursor' => Lighthouse::GraphQL::Pagination.encode_cursor(@offset + i + 1), 'node' => node } }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def pageInfo = info # rubocop:disable Naming/MethodName -- the GraphQL field's name, which the default resolver reads
|
|
161
|
+
|
|
162
|
+
def info
|
|
163
|
+
@info ||= {
|
|
164
|
+
'hasNextPage' => @offset + @items.size < total,
|
|
165
|
+
'hasPreviousPage' => @offset.positive?,
|
|
166
|
+
'startCursor' => @items.empty? ? nil : Lighthouse::GraphQL::Pagination.encode_cursor(@offset + 1),
|
|
167
|
+
'endCursor' => @items.empty? ? nil : Lighthouse::GraphQL::Pagination.encode_cursor(@offset + @items.size),
|
|
168
|
+
'total' => total,
|
|
169
|
+
'count' => @items.size,
|
|
170
|
+
'currentPage' => @first.zero? ? 1 : (@offset / @first) + 1,
|
|
171
|
+
'lastPage' => @first.zero? ? 0 : [(total.to_f / @first).ceil, 1].max
|
|
172
|
+
}
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def marshal_dump
|
|
176
|
+
[info, @items, @offset]
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def marshal_load((info, items, offset))
|
|
180
|
+
super([info, items])
|
|
181
|
+
@offset = offset
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def item_range(items, page, per_page)
|
|
186
|
+
first_item = items.empty? ? nil : ((page - 1) * per_page) + 1
|
|
187
|
+
{
|
|
188
|
+
'count' => items.size,
|
|
189
|
+
'currentPage' => page,
|
|
190
|
+
'firstItem' => first_item,
|
|
191
|
+
'lastItem' => first_item && (first_item + items.size - 1),
|
|
192
|
+
'perPage' => per_page
|
|
193
|
+
}
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def window(relation, offset, limit)
|
|
197
|
+
return [] if limit.zero?
|
|
198
|
+
return Array(relation)[offset, limit] || [] unless relation.respond_to?(:offset)
|
|
199
|
+
|
|
200
|
+
relation.offset(offset).limit(limit).to_a
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def count(relation)
|
|
204
|
+
return Array(relation).size unless relation.respond_to?(:unscope)
|
|
205
|
+
|
|
206
|
+
relation.unscope(:order, :limit, :offset).count
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def encode_cursor(position)
|
|
210
|
+
Base64.strict_encode64(position.to_s)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# The number of items the `after` cursor points past (0 for none or garbage).
|
|
214
|
+
def offset_after(cursor)
|
|
215
|
+
return 0 if cursor.blank?
|
|
216
|
+
|
|
217
|
+
Integer(Base64.strict_decode64(cursor), 10)
|
|
218
|
+
rescue ArgumentError
|
|
219
|
+
0
|
|
220
|
+
end
|
|
221
|
+
end
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
# Automatic Persisted Queries, compatible with Apollo's protocol (as PHP
|
|
7
|
+
# Lighthouse supports them), plus a manifest of trusted operations.
|
|
8
|
+
#
|
|
9
|
+
# APQ: a client sends `extensions: { persistedQuery: { version: 1,
|
|
10
|
+
# sha256Hash } }` without the query. A known hash is served from
|
|
11
|
+
# Config.persisted_queries_store; an unknown one answers
|
|
12
|
+
# PersistedQueryNotFound, and the client retries with the query and the
|
|
13
|
+
# hash, which registers it. Requests shrink to a hash, and fit in a GET —
|
|
14
|
+
# which CDNs can cache (see @cacheControl).
|
|
15
|
+
#
|
|
16
|
+
# Manifest: `Config.persisted_queries_manifest` points at a JSON file of
|
|
17
|
+
# operations — Apollo's persisted query manifest
|
|
18
|
+
# (`{ "operations": [{ "id": sha256, "body": query }] }`) or a plain
|
|
19
|
+
# `{ sha256 => query }` object — generated by your client build. Its
|
|
20
|
+
# operations are served from memory, never evicted. With
|
|
21
|
+
# `persisted_queries_only`, nothing else runs: an allowlist ("trusted
|
|
22
|
+
# documents"), so arbitrary queries cannot reach the API.
|
|
23
|
+
#
|
|
24
|
+
# Off by default. Run operations through `PersistedQueries.execute` (the
|
|
25
|
+
# generated controller does).
|
|
26
|
+
module Lighthouse::GraphQL::PersistedQueries
|
|
27
|
+
HASH = /\A\h{64}\z/
|
|
28
|
+
|
|
29
|
+
# A request the persisted-query protocol rejects, rendered as a GraphQL
|
|
30
|
+
# response with Apollo's error codes.
|
|
31
|
+
class Error < StandardError
|
|
32
|
+
attr_reader :code
|
|
33
|
+
|
|
34
|
+
def initialize(message, code)
|
|
35
|
+
super(message)
|
|
36
|
+
@code = code
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def to_h
|
|
40
|
+
{ 'errors' => [{ 'message' => message, 'extensions' => { 'code' => code } }] }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
NOT_FOUND = %w[PersistedQueryNotFound PERSISTED_QUERY_NOT_FOUND].freeze
|
|
45
|
+
NOT_SUPPORTED = %w[PersistedQueryNotSupported PERSISTED_QUERY_NOT_SUPPORTED].freeze
|
|
46
|
+
HASH_MISMATCH = ['provided sha does not match query', 'INTERNAL_SERVER_ERROR'].freeze
|
|
47
|
+
NOT_IN_LIST = ['Only persisted queries are allowed.', 'PERSISTED_QUERY_NOT_IN_LIST'].freeze
|
|
48
|
+
|
|
49
|
+
module_function
|
|
50
|
+
|
|
51
|
+
# `schema.execute` with the query the request names; a protocol error
|
|
52
|
+
# comes back as a ready-to-render { "errors" => [...] } Hash.
|
|
53
|
+
def execute(schema, query:, extensions: nil, **options)
|
|
54
|
+
schema.execute(resolve(query, extensions), **options)
|
|
55
|
+
rescue Error => e
|
|
56
|
+
e.to_h
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The query text to run for a request's `query` and `extensions`.
|
|
60
|
+
def resolve(query, extensions)
|
|
61
|
+
persisted = persisted_query(extensions)
|
|
62
|
+
query = nil if query.respond_to?(:empty?) && query.empty?
|
|
63
|
+
return unpersisted(query) unless persisted
|
|
64
|
+
|
|
65
|
+
hash = supported_hash(persisted)
|
|
66
|
+
query ? register_from_request(query, hash) : lookup(hash)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The hash a `persistedQuery` extension names, provided persisted queries
|
|
70
|
+
# are on and it speaks the one protocol version there is.
|
|
71
|
+
def supported_hash(persisted)
|
|
72
|
+
raise Error.new(*NOT_SUPPORTED) unless enabled?
|
|
73
|
+
|
|
74
|
+
hash = persisted['sha256Hash'].to_s
|
|
75
|
+
raise Error.new(*NOT_SUPPORTED) unless persisted['version'].to_i == 1 && hash.match?(HASH)
|
|
76
|
+
|
|
77
|
+
hash
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def enabled?
|
|
81
|
+
Lighthouse::Config.persisted_queries
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def digest(query)
|
|
85
|
+
Digest::SHA256.hexdigest(query)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Store a query ahead of time (e.g. from a deploy task). Returns its hash.
|
|
89
|
+
def register(query)
|
|
90
|
+
digest(query).tap { |hash| store.write(key(hash), query, expires_in: Lighthouse::Config.persisted_queries_ttl) }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def lookup(hash)
|
|
94
|
+
manifest[hash] || (!only? && store.read(key(hash))) || raise(Error.new(*NOT_FOUND))
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# The manifest's operations by hash, reloaded when the file changes.
|
|
98
|
+
def manifest
|
|
99
|
+
path = Lighthouse::Config.persisted_queries_manifest
|
|
100
|
+
return {} unless path
|
|
101
|
+
|
|
102
|
+
mtime = File.mtime(path.to_s)
|
|
103
|
+
return @manifest if @manifest_source == [path.to_s, mtime]
|
|
104
|
+
|
|
105
|
+
@manifest = load_manifest(path.to_s)
|
|
106
|
+
@manifest_source = [path.to_s, mtime]
|
|
107
|
+
@manifest
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def load_manifest(path)
|
|
111
|
+
data = JSON.parse(File.read(path))
|
|
112
|
+
operations = data.is_a?(Hash) && data['operations'].is_a?(Array) ? data['operations'].to_h { |op| [op['id'], op['body']] } : data
|
|
113
|
+
operations.each do |hash, body|
|
|
114
|
+
next if digest(body.to_s) == hash
|
|
115
|
+
|
|
116
|
+
raise Lighthouse::DefinitionError, "#{path}: operation #{hash} is not the SHA-256 of its body."
|
|
117
|
+
end
|
|
118
|
+
operations
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def store
|
|
122
|
+
Lighthouse::Config.persisted_queries_store
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def key(hash)
|
|
126
|
+
"lighthouse:apq:#{hash}"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def only?
|
|
130
|
+
Lighthouse::Config.persisted_queries_only
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def persisted_query(extensions)
|
|
134
|
+
extensions = extensions_hash(extensions)
|
|
135
|
+
return nil unless extensions.respond_to?(:to_h)
|
|
136
|
+
|
|
137
|
+
persisted = extensions.to_h.transform_keys(&:to_s)['persistedQuery']
|
|
138
|
+
persisted&.to_h&.transform_keys(&:to_s)
|
|
139
|
+
rescue JSON::ParserError
|
|
140
|
+
nil
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# `extensions` as the request carries it — a JSON string (GET) or
|
|
144
|
+
# ActionController::Parameters — made a plain Hash where possible. Malformed
|
|
145
|
+
# JSON raises JSON::ParserError, which persisted_query treats as "none".
|
|
146
|
+
def extensions_hash(extensions)
|
|
147
|
+
extensions = JSON.parse(extensions) if extensions.is_a?(String) && !extensions.empty?
|
|
148
|
+
extensions.respond_to?(:to_unsafe_h) ? extensions.to_unsafe_h : extensions
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# A plain query: allowed, unless only the manifest's operations may run.
|
|
152
|
+
def unpersisted(query)
|
|
153
|
+
return query unless only?
|
|
154
|
+
raise Error.new(*NOT_IN_LIST) unless query && manifest.key?(digest(query))
|
|
155
|
+
|
|
156
|
+
query
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def register_from_request(query, hash)
|
|
160
|
+
raise Error.new(*HASH_MISMATCH) unless digest(query) == hash
|
|
161
|
+
return query if manifest.key?(hash)
|
|
162
|
+
raise Error.new(*NOT_IN_LIST) if only?
|
|
163
|
+
|
|
164
|
+
store.write(key(hash), query, expires_in: Lighthouse::Config.persisted_queries_ttl)
|
|
165
|
+
query
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Hooks Lighthouse into a Rails host: exposes the `lighthouse:*` rake tasks,
|
|
4
|
+
# and — with `config.watch_sdl` — reloads the app when a .graphql file changes.
|
|
5
|
+
# Generators (`rails g lighthouse:install`) are found through lib/generators.
|
|
6
|
+
class Lighthouse::Railtie < Rails::Railtie
|
|
7
|
+
rake_tasks do
|
|
8
|
+
load File.expand_path('tasks/lighthouse.rake', __dir__)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# After config/initializers (where Lighthouse.configure sets watch_sdl) and
|
|
12
|
+
# before Rails builds its file watcher from config.watchable_dirs.
|
|
13
|
+
initializer 'lighthouse.watch_sdl', after: :load_config_initializers, before: :set_clear_dependencies_hook do |app|
|
|
14
|
+
Lighthouse::Railtie.watch_sdl(app.config)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Rails' reloader then treats a .graphql edit like a Ruby one: the app's
|
|
18
|
+
# constants — the schema among them — are reloaded on the next request.
|
|
19
|
+
def self.watch_sdl(config)
|
|
20
|
+
folder = Lighthouse::Config.sdl_folder
|
|
21
|
+
return false unless Lighthouse::Config.watch_sdl && folder
|
|
22
|
+
|
|
23
|
+
config.watchable_dirs[folder.to_s] = %w[graphql graphqls]
|
|
24
|
+
true
|
|
25
|
+
end
|
|
26
|
+
end
|