1# frozen_string_literal: true 2 3require 'find' 4 5module Gitlab 6 module Graphql 7 module Queries 8 IMPORT_RE = /^#\s*import "(?<path>[^"]+)"$/m.freeze 9 EE_ELSE_CE = /^ee_else_ce/.freeze 10 HOME_RE = /^~/.freeze 11 HOME_EE = %r{^ee/}.freeze 12 DOTS_RE = %r{^(\.\./)+}.freeze 13 DOT_RE = %r{^\./}.freeze 14 IMPLICIT_ROOT = %r{^app/}.freeze 15 CONN_DIRECTIVE = /@connection\(key: "\w+"\)/.freeze 16 17 class WrappedError 18 delegate :message, to: :@error 19 20 def initialize(error) 21 @error = error 22 end 23 24 def path 25 [] 26 end 27 end 28 29 class FileNotFound 30 def initialize(file) 31 @file = file 32 end 33 34 def message 35 "File not found: #{@file}" 36 end 37 38 def path 39 [] 40 end 41 end 42 43 # We need to re-write queries to remove all @client fields. Ideally we 44 # would do that as a source-to-source transformation of the AST, but doing it using a 45 # printer is much simpler. 46 class ClientFieldRedactor < GraphQL::Language::Printer 47 attr_reader :fields_printed, :skipped_arguments, :printed_arguments, :used_fragments 48 49 def initialize(skips = true) 50 @skips = skips 51 @fields_printed = 0 52 @in_operation = false 53 @skipped_arguments = [].to_set 54 @printed_arguments = [].to_set 55 @used_fragments = [].to_set 56 @skipped_fragments = [].to_set 57 @used_fragments = [].to_set 58 end 59 60 def print_variable_identifier(variable_identifier) 61 @printed_arguments << variable_identifier.name 62 super 63 end 64 65 def print_fragment_spread(fragment_spread, indent: "") 66 @used_fragments << fragment_spread.name 67 super 68 end 69 70 def print_operation_definition(op, indent: "") 71 @in_operation = true 72 out = +"#{indent}#{op.operation_type}" 73 out << " #{op.name}" if op.name 74 75 # Do these first, so that we detect any skipped arguments 76 dirs = print_directives(op.directives) 77 sels = print_selections(op.selections, indent: indent) 78 79 # remove variable definitions only used in skipped (client) fields 80 vars = op.variables.reject do |v| 81 @skipped_arguments.include?(v.name) && !@printed_arguments.include?(v.name) 82 end 83 84 if vars.any? 85 out << "(#{vars.map { |v| print_variable_definition(v) }.join(", ")})" 86 end 87 88 out + dirs + sels 89 ensure 90 @in_operation = false 91 end 92 93 def print_field(field, indent: '') 94 if skips? && field.directives.any? { |d| d.name == 'client' } 95 skipped = self.class.new(false) 96 97 skipped.print_node(field) 98 @skipped_fragments |= skipped.used_fragments 99 @skipped_arguments |= skipped.printed_arguments 100 101 return '' 102 end 103 104 ret = super 105 106 @fields_printed += 1 if @in_operation && ret != '' 107 108 ret 109 end 110 111 def print_fragment_definition(fragment_def, indent: "") 112 if skips? && @skipped_fragments.include?(fragment_def.name) && !@used_fragments.include?(fragment_def.name) 113 return '' 114 end 115 116 super 117 end 118 119 def skips? 120 @skips 121 end 122 end 123 124 class Definition 125 attr_reader :file, :imports 126 127 def initialize(path, fragments) 128 @file = path 129 @fragments = fragments 130 @imports = [] 131 @errors = [] 132 @ee_else_ce = [] 133 end 134 135 def text(mode: :ce) 136 qs = [query] + all_imports(mode: mode).uniq.sort.map { |p| fragment(p).query } 137 t = qs.join("\n\n").gsub(/\n\n+/, "\n\n") 138 139 return t unless /@client/.match?(t) 140 141 doc = ::GraphQL.parse(t) 142 printer = ClientFieldRedactor.new 143 redacted = doc.dup.to_query_string(printer: printer) 144 145 return redacted if printer.fields_printed > 0 146 end 147 148 def complexity(schema) 149 # See BaseResolver::resolver_complexity 150 # we want to see the max possible complexity. 151 fake_args = Struct 152 .new(:if, :keyword_arguments) 153 .new(nil, { sort: true, search: true }) 154 155 query = GraphQL::Query.new(schema, text) 156 # We have no arguments, so fake them. 157 query.define_singleton_method(:arguments_for) { |_x, _y| fake_args } 158 159 GraphQL::Analysis::AST.analyze_query(query, [GraphQL::Analysis::AST::QueryComplexity]).first 160 end 161 162 def query 163 return @query if defined?(@query) 164 165 # CONN_DIRECTIVEs are purely client-side constructs 166 @query = File.read(file).gsub(CONN_DIRECTIVE, '').gsub(IMPORT_RE) do 167 path = $~[:path] 168 169 if EE_ELSE_CE.match?(path) 170 @ee_else_ce << path.gsub(EE_ELSE_CE, '') 171 else 172 @imports << fragment_path(path) 173 end 174 175 '' 176 end 177 rescue Errno::ENOENT 178 @errors << FileNotFound.new(file) 179 @query = nil 180 end 181 182 def all_imports(mode: :ce) 183 return [] if query.nil? 184 185 home = mode == :ee ? @fragments.home_ee : @fragments.home 186 eithers = @ee_else_ce.map { |p| home + p } 187 188 (imports + eithers).flat_map { |p| [p] + @fragments.get(p).all_imports(mode: mode) } 189 end 190 191 def all_errors 192 return @errors.to_set if query.nil? 193 194 paths = imports + @ee_else_ce.flat_map { |p| [@fragments.home + p, @fragments.home_ee + p] } 195 196 paths.map { |p| fragment(p).all_errors }.reduce(@errors.to_set) { |a, b| a | b } 197 end 198 199 def validate(schema) 200 return [:client_query, []] if query.present? && text.nil? 201 202 errs = all_errors.presence || schema.validate(text) 203 if @ee_else_ce.present? 204 errs += schema.validate(text(mode: :ee)) 205 end 206 207 [:validated, errs] 208 rescue ::GraphQL::ParseError => e 209 [:validated, [WrappedError.new(e)]] 210 end 211 212 private 213 214 def fragment(path) 215 @fragments.get(path) 216 end 217 218 def fragment_path(import_path) 219 frag_path = import_path.gsub(HOME_RE, @fragments.home) 220 frag_path = frag_path.gsub(HOME_EE, @fragments.home_ee + '/') 221 frag_path = frag_path.gsub(DOT_RE) do 222 Pathname.new(file).parent.to_s + '/' 223 end 224 frag_path = frag_path.gsub(DOTS_RE) do |dots| 225 rel_dir(dots.split('/').count) 226 end 227 frag_path.gsub(IMPLICIT_ROOT) do 228 (Rails.root / 'app').to_s + '/' 229 end 230 end 231 232 def rel_dir(n_steps_up) 233 path = Pathname.new(file).parent 234 while n_steps_up > 0 235 path = path.parent 236 n_steps_up -= 1 237 end 238 239 path.to_s + '/' 240 end 241 end 242 243 class Fragments 244 def initialize(root, dir = 'app/assets/javascripts') 245 @root = root 246 @store = {} 247 @dir = dir 248 end 249 250 def home 251 @home ||= (@root / @dir).to_s 252 end 253 254 def home_ee 255 @home_ee ||= (@root / 'ee' / @dir).to_s 256 end 257 258 def get(frag_path) 259 @store[frag_path] ||= Definition.new(frag_path, self) 260 end 261 end 262 263 def self.find(root) 264 definitions = [] 265 266 ::Find.find(root.to_s) do |path| 267 definitions << Definition.new(path, fragments) if query_for_gitlab_schema?(path) 268 end 269 270 definitions 271 rescue Errno::ENOENT 272 [] # root does not exist 273 end 274 275 def self.fragments 276 @fragments ||= Fragments.new(Rails.root) 277 end 278 279 def self.all 280 ['.', 'ee'].flat_map do |prefix| 281 find(Rails.root / prefix / 'app/assets/javascripts') 282 end 283 end 284 285 def self.known_failure?(path) 286 @known_failures ||= YAML.safe_load(File.read(Rails.root.join('config', 'known_invalid_graphql_queries.yml'))) 287 288 @known_failures.fetch('filenames', []).any? { |known_failure| path.to_s.ends_with?(known_failure) } 289 end 290 291 def self.query_for_gitlab_schema?(path) 292 path.ends_with?('.graphql') && 293 !path.ends_with?('.fragment.graphql') && 294 !path.ends_with?('typedefs.graphql') && 295 !/.*\.customer\.(query|mutation)\.graphql$/.match?(path) 296 end 297 end 298 end 299end 300