lighthouse-graphql 0.1.0 → 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.
Files changed (108) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +168 -1
  3. data/README.md +46 -2
  4. data/docs/authorization.md +116 -57
  5. data/docs/best-practices.md +16 -3
  6. data/docs/caching.md +161 -0
  7. data/docs/configuration.md +83 -4
  8. data/docs/custom-directives.md +5 -1
  9. data/docs/directives.md +117 -38
  10. data/docs/getting-started.md +84 -0
  11. data/docs/mutations.md +260 -0
  12. data/docs/performance.md +138 -0
  13. data/docs/relationships.md +80 -33
  14. data/docs/resolvers.md +179 -0
  15. data/docs/validation.md +132 -0
  16. data/lib/generators/lighthouse/builder_generator.rb +30 -0
  17. data/lib/generators/lighthouse/class_generator.rb +61 -0
  18. data/lib/generators/lighthouse/install/install_generator.rb +127 -0
  19. data/lib/generators/lighthouse/install/templates/graphql.config.yml.tt +8 -0
  20. data/lib/generators/lighthouse/install/templates/graphql_controller.rb.tt +57 -0
  21. data/lib/generators/lighthouse/install/templates/initializer.rb.tt +67 -0
  22. data/lib/generators/lighthouse/install/templates/schema.graphql.tt +19 -0
  23. data/lib/generators/lighthouse/install/templates/schema.rb.tt +10 -0
  24. data/lib/generators/lighthouse/mutation_generator.rb +30 -0
  25. data/lib/generators/lighthouse/query_generator.rb +29 -0
  26. data/lib/generators/lighthouse/validator_generator.rb +36 -0
  27. data/lib/lighthouse/cache_control.rb +126 -0
  28. data/lib/lighthouse/caching.rb +68 -0
  29. data/lib/lighthouse/config.rb +85 -19
  30. data/lib/lighthouse/contracts/arg_builder.rb +8 -12
  31. data/lib/lighthouse/contracts/arg_manipulator.rb +8 -12
  32. data/lib/lighthouse/contracts/directive.rb +10 -14
  33. data/lib/lighthouse/contracts/field_manipulator.rb +8 -12
  34. data/lib/lighthouse/contracts/field_middleware.rb +7 -11
  35. data/lib/lighthouse/contracts/field_resolver.rb +9 -13
  36. data/lib/lighthouse/dataloader_sources/association_loader.rb +28 -19
  37. data/lib/lighthouse/dataloader_sources/model_loader.rb +10 -14
  38. data/lib/lighthouse/dataloader_sources/relation_count_loader.rb +16 -0
  39. data/lib/lighthouse/dataloader_sources/relation_page_loader.rb +18 -0
  40. data/lib/lighthouse/directive_resolvers/aggregate_resolver.rb +57 -69
  41. data/lib/lighthouse/directive_resolvers/all_resolver.rb +26 -57
  42. data/lib/lighthouse/directive_resolvers/association_resolvers.rb +161 -0
  43. data/lib/lighthouse/directive_resolvers/auth_resolver.rb +41 -38
  44. data/lib/lighthouse/directive_resolvers/base.rb +142 -86
  45. data/lib/lighthouse/directive_resolvers/builder_applier.rb +58 -56
  46. data/lib/lighthouse/directive_resolvers/cache_resolvers.rb +133 -0
  47. data/lib/lighthouse/directive_resolvers/can_resolvers.rb +177 -0
  48. data/lib/lighthouse/directive_resolvers/convention_resolver.rb +70 -0
  49. data/lib/lighthouse/directive_resolvers/count_resolver.rb +42 -48
  50. data/lib/lighthouse/directive_resolvers/field_resolver.rb +27 -68
  51. data/lib/lighthouse/directive_resolvers/find_resolver.rb +42 -52
  52. data/lib/lighthouse/directive_resolvers/first_resolver.rb +39 -45
  53. data/lib/lighthouse/directive_resolvers/guard_resolver.rb +16 -22
  54. data/lib/lighthouse/directive_resolvers/method_resolver.rb +19 -25
  55. data/lib/lighthouse/directive_resolvers/model_mutation_resolver.rb +219 -0
  56. data/lib/lighthouse/directive_resolvers/paginate_resolver.rb +70 -128
  57. data/lib/lighthouse/directive_resolvers/rename_resolver.rb +18 -24
  58. data/lib/lighthouse/directive_resolvers/resolution.rb +31 -27
  59. data/lib/lighthouse/directive_resolvers/validator_resolver.rb +193 -0
  60. data/lib/lighthouse/directive_resolvers/where_conditions_applier.rb +164 -163
  61. data/lib/lighthouse/directives/arguments/base.rb +23 -31
  62. data/lib/lighthouse/directives/arguments/filters.rb +113 -93
  63. data/lib/lighthouse/directives/arguments/order_by.rb +41 -41
  64. data/lib/lighthouse/directives/pagination_info_types.rb +69 -0
  65. data/lib/lighthouse/directives/pagination_manipulator.rb +176 -0
  66. data/lib/lighthouse/directives/registry.rb +210 -141
  67. data/lib/lighthouse/directives/where_conditions_directive.rb +236 -200
  68. data/lib/lighthouse/errors.rb +27 -0
  69. data/lib/lighthouse/federation_directives.rb +53 -0
  70. data/lib/lighthouse/field_resolver_stack.rb +56 -0
  71. data/lib/lighthouse/generated_resolvers.rb +82 -0
  72. data/lib/lighthouse/graphql/version.rb +1 -1
  73. data/lib/lighthouse/graphql_ruby_patches.rb +18 -0
  74. data/lib/lighthouse/ide_helper.rb +98 -0
  75. data/lib/lighthouse/namespaces.rb +21 -0
  76. data/lib/lighthouse/nested_mutations/belongs_to_operations.rb +50 -0
  77. data/lib/lighthouse/nested_mutations/executor.rb +149 -0
  78. data/lib/lighthouse/nested_mutations/has_operations.rb +66 -0
  79. data/lib/lighthouse/nested_mutations/many_to_many_operations.rb +44 -0
  80. data/lib/lighthouse/nested_mutations/partitioner.rb +125 -0
  81. data/lib/lighthouse/nested_mutations.rb +210 -0
  82. data/lib/lighthouse/pagination.rb +221 -0
  83. data/lib/lighthouse/persisted_queries.rb +167 -0
  84. data/lib/lighthouse/railtie.rb +26 -0
  85. data/lib/lighthouse/rb_lighthouse.rb +50 -29
  86. data/lib/lighthouse/reference_resolver.rb +13 -15
  87. data/lib/lighthouse/relation_batch.rb +156 -0
  88. data/lib/lighthouse/resolver_dispatch.rb +26 -0
  89. data/lib/lighthouse/schema_cache.rb +120 -0
  90. data/lib/lighthouse/schema_factory.rb +27 -37
  91. data/lib/lighthouse/schema_generator.rb +196 -239
  92. data/lib/lighthouse/schema_implementation.rb +51 -48
  93. data/lib/lighthouse/sdl_loader.rb +17 -20
  94. data/lib/lighthouse/support/arguments.rb +119 -0
  95. data/lib/lighthouse/support/authorization.rb +18 -19
  96. data/lib/lighthouse/support/batching.rb +16 -0
  97. data/lib/lighthouse/support/directive_args.rb +26 -24
  98. data/lib/lighthouse/support/model_resolver.rb +56 -56
  99. data/lib/lighthouse/support/naming.rb +55 -59
  100. data/lib/lighthouse/tasks/lighthouse.rake +33 -0
  101. data/lib/lighthouse/where_conditions/operator_map.rb +25 -29
  102. metadata +52 -64
  103. data/lib/lighthouse/directive_resolvers/belongs_to_many_resolver.rb +0 -56
  104. data/lib/lighthouse/directive_resolvers/belongs_to_resolver.rb +0 -47
  105. data/lib/lighthouse/directive_resolvers/can_resolver.rb +0 -86
  106. data/lib/lighthouse/directive_resolvers/has_many_resolver.rb +0 -109
  107. data/lib/lighthouse/directive_resolvers/has_one_resolver.rb +0 -43
  108. 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