diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 79ca127e42f6b9b42628a14efb8b2fa0079f5d5d..b41d7bcd34ffc944f66a9ce2300f2c662e853e13 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -110,3 +110,4 @@ include: - local: .gitlab/ci/notify.gitlab-ci.yml - local: .gitlab/ci/dast.gitlab-ci.yml - local: .gitlab/ci/workhorse.gitlab-ci.yml + - local: .gitlab/ci/graphql.gitlab-ci.yml diff --git a/.gitlab/ci/docs.gitlab-ci.yml b/.gitlab/ci/docs.gitlab-ci.yml index d6dc709a11a0c89fdb4335cd7bee087a67d0d744..c9eb782935b40f878eabbc10db18f028e858b8ee 100644 --- a/.gitlab/ci/docs.gitlab-ci.yml +++ b/.gitlab/ci/docs.gitlab-ci.yml @@ -84,16 +84,3 @@ ui-docs-links lint: needs: [] script: - bundle exec haml-lint -i DocumentationLinks - -graphql-reference-verify: - extends: - - .default-retry - - .rails-cache - - .default-before_script - - .docs:rules:graphql-reference-verify - - .use-pg11 - stage: test - needs: ["setup-test-env"] - script: - - bundle exec rake gitlab:graphql:check_docs - - bundle exec rake gitlab:graphql:check_schema diff --git a/.gitlab/ci/graphql.gitlab-ci.yml b/.gitlab/ci/graphql.gitlab-ci.yml new file mode 100644 index 0000000000000000000000000000000000000000..4aff0ef630672c706a1ff2c94938500690d846b2 --- /dev/null +++ b/.gitlab/ci/graphql.gitlab-ci.yml @@ -0,0 +1,14 @@ +graphql-verify: + variables: + SETUP_DB: "false" + extends: + - .default-retry + - .rails-cache + - .default-before_script + - .graphql:rules:graphql-verify + stage: test + needs: [] + script: + - bundle exec rake gitlab:graphql:validate + - bundle exec rake gitlab:graphql:check_docs + - bundle exec rake gitlab:graphql:check_schema diff --git a/.gitlab/ci/rules.gitlab-ci.yml b/.gitlab/ci/rules.gitlab-ci.yml index 43e7b7ab7451539bedbc5980332e30a4c112f4a0..9ea5538f643483e7ad000043ecf3dbd38ece2b0b 100644 --- a/.gitlab/ci/rules.gitlab-ci.yml +++ b/.gitlab/ci/rules.gitlab-ci.yml @@ -349,7 +349,11 @@ changes: *docs-patterns when: on_success -.docs:rules:graphql-reference-verify: +################## +# GraphQL rules # +################## + +.graphql:rules:graphql-verify: rules: - <<: *if-not-ee when: never diff --git a/app/assets/javascripts/repository/queries/project_path.query.graphql b/app/assets/javascripts/repository/queries/project_path.query.graphql index 74e73e075773cee21f97b48b2acbcd0aad2381ba..9e5c10b3de3b6b5c6b25e2a026528d1827cf52b2 100644 --- a/app/assets/javascripts/repository/queries/project_path.query.graphql +++ b/app/assets/javascripts/repository/queries/project_path.query.graphql @@ -1,3 +1,3 @@ query getProjectPath { - projectPath + projectPath @client } diff --git a/config/known_invalid_graphql_queries.yml b/config/known_invalid_graphql_queries.yml new file mode 100644 index 0000000000000000000000000000000000000000..770366d76cf35f7dd2accab6c8f8d95a3f68e1aa --- /dev/null +++ b/config/known_invalid_graphql_queries.yml @@ -0,0 +1,4 @@ +--- +filenames: + - ee/app/assets/javascripts/on_demand_scans/graphql/dast_scan_create.mutation.graphql + - ee/app/assets/javascripts/oncall_schedules/graphql/mutations/update_oncall_schedule_rotation.mutation.graphql diff --git a/ee/app/assets/javascripts/security_dashboard/graphql/group_specific_scanners.query.graphql b/ee/app/assets/javascripts/security_dashboard/graphql/group_specific_scanners.query.graphql index da3fcef1454af78c2b27f4113b4171a836364348..dac08118c590e697a3ad4f16d70bb8955b7ecf4a 100644 --- a/ee/app/assets/javascripts/security_dashboard/graphql/group_specific_scanners.query.graphql +++ b/ee/app/assets/javascripts/security_dashboard/graphql/group_specific_scanners.query.graphql @@ -1,4 +1,4 @@ -#import "./vulnerablity_scanner.fragment.graphql" +#import "./vulnerability_scanner.fragment.graphql" query groupSpecificScanners($fullPath: ID!) { group(fullPath: $fullPath) { diff --git a/ee/app/assets/javascripts/security_dashboard/graphql/instance_specific_scanners.query.graphql b/ee/app/assets/javascripts/security_dashboard/graphql/instance_specific_scanners.query.graphql index 8e569303888a9b942257d4886a7cbb2c0fc2aa01..75524586e69e1bbe0e4f5353d4b09c6c4eb33764 100644 --- a/ee/app/assets/javascripts/security_dashboard/graphql/instance_specific_scanners.query.graphql +++ b/ee/app/assets/javascripts/security_dashboard/graphql/instance_specific_scanners.query.graphql @@ -1,4 +1,4 @@ -#import "./vulnerablity_scanner.fragment.graphql" +#import "./vulnerability_scanner.fragment.graphql" query instanceSpecificScanners { instanceSecurityDashboard { diff --git a/ee/app/assets/javascripts/security_dashboard/graphql/project_specific_scanners.query.graphql b/ee/app/assets/javascripts/security_dashboard/graphql/project_specific_scanners.query.graphql index f7f985253144dc56f46f330d51c8b0842505f1d3..b916d586a8242ab04f0456e9afdc00f80e05a24b 100644 --- a/ee/app/assets/javascripts/security_dashboard/graphql/project_specific_scanners.query.graphql +++ b/ee/app/assets/javascripts/security_dashboard/graphql/project_specific_scanners.query.graphql @@ -1,7 +1,7 @@ -#import "./vulnerablity_scanner.fragment.graphql" +#import "./vulnerability_scanner.fragment.graphql" -query projectSpecificScanners($fullpath: id!) { - project(fullPath: $fullPath) { +query projectSpecificScanners($fullpath: ID!) { + project(fullPath: $fullpath) { vulnerabilityScanners { nodes { ...VulnerabilityScanner diff --git a/fixtures/lib/gitlab/graphql/queries/author.fragment.graphql b/fixtures/lib/gitlab/graphql/queries/author.fragment.graphql new file mode 100644 index 0000000000000000000000000000000000000000..a10af1b3217823f2cd83ac2ddeda2c1ca8e17b48 --- /dev/null +++ b/fixtures/lib/gitlab/graphql/queries/author.fragment.graphql @@ -0,0 +1,4 @@ +fragment AuthorF on Author { + name + handle +} diff --git a/fixtures/lib/gitlab/graphql/queries/bad.fragment.graphql b/fixtures/lib/gitlab/graphql/queries/bad.fragment.graphql new file mode 100644 index 0000000000000000000000000000000000000000..00d868792d2847f4350257adf63765cb27c03f49 --- /dev/null +++ b/fixtures/lib/gitlab/graphql/queries/bad.fragment.graphql @@ -0,0 +1,4 @@ +fragment BadF on Blog { + wibble + wobble +} diff --git a/fixtures/lib/gitlab/graphql/queries/bad_argument.graphql b/fixtures/lib/gitlab/graphql/queries/bad_argument.graphql new file mode 100644 index 0000000000000000000000000000000000000000..0b704d717dd5f4be8ee2a63e34555547a7558d4a --- /dev/null +++ b/fixtures/lib/gitlab/graphql/queries/bad_argument.graphql @@ -0,0 +1,5 @@ +query($bad: String) { + blog(title: $bad) { + description + } +} diff --git a/fixtures/lib/gitlab/graphql/queries/client.query.graphql b/fixtures/lib/gitlab/graphql/queries/client.query.graphql new file mode 100644 index 0000000000000000000000000000000000000000..dabe9735064dfb6a95dac9a5257b65bfc1ae76bd --- /dev/null +++ b/fixtures/lib/gitlab/graphql/queries/client.query.graphql @@ -0,0 +1,3 @@ +query { + thingy @client +} diff --git a/fixtures/lib/gitlab/graphql/queries/client_unused_fragment.graphql b/fixtures/lib/gitlab/graphql/queries/client_unused_fragment.graphql new file mode 100644 index 0000000000000000000000000000000000000000..2beba1812c94074e809155175e2c613beee8618e --- /dev/null +++ b/fixtures/lib/gitlab/graphql/queries/client_unused_fragment.graphql @@ -0,0 +1,7 @@ +#import "./thingy.fragment.graphql" + +query($slug: String!, $foo: String) { + thingy(someArg: $foo) @client { + ...ThingyF + } +} diff --git a/fixtures/lib/gitlab/graphql/queries/connection.query.graphql b/fixtures/lib/gitlab/graphql/queries/connection.query.graphql new file mode 100644 index 0000000000000000000000000000000000000000..eec3f9b867b6c18ac31ea999df7f05429b750f24 --- /dev/null +++ b/fixtures/lib/gitlab/graphql/queries/connection.query.graphql @@ -0,0 +1,9 @@ +query($slug: String!) { + post(slug: $slug) { + author { + posts @connection(key: "posts") { + title + } + } + } +} diff --git a/fixtures/lib/gitlab/graphql/queries/deeply/nested/bad_import.graphql b/fixtures/lib/gitlab/graphql/queries/deeply/nested/bad_import.graphql new file mode 100644 index 0000000000000000000000000000000000000000..2a83b9dd42c437d07d52a1cf877734c2276c3b67 --- /dev/null +++ b/fixtures/lib/gitlab/graphql/queries/deeply/nested/bad_import.graphql @@ -0,0 +1,7 @@ +# import "../author.fragment.graphql" + +query($slug: String!) { + post(slug: $slug) { + author { ...AuthorF } + } +} diff --git a/fixtures/lib/gitlab/graphql/queries/deeply/nested/query.graphql b/fixtures/lib/gitlab/graphql/queries/deeply/nested/query.graphql new file mode 100644 index 0000000000000000000000000000000000000000..451d3c25f25179c01b360a60de2d7b99f6007cee --- /dev/null +++ b/fixtures/lib/gitlab/graphql/queries/deeply/nested/query.graphql @@ -0,0 +1,7 @@ +# import "../../author.fragment.graphql" + +query($slug: String!) { + post(slug: $slug) { + author { ...AuthorF } + } +} diff --git a/fixtures/lib/gitlab/graphql/queries/duplicate_imports.graphql b/fixtures/lib/gitlab/graphql/queries/duplicate_imports.graphql new file mode 100644 index 0000000000000000000000000000000000000000..de3ac9fa833658d0aebb56397300efc44fc411f7 --- /dev/null +++ b/fixtures/lib/gitlab/graphql/queries/duplicate_imports.graphql @@ -0,0 +1,10 @@ +# import "./author.fragment.graphql" +# import "./post.fragment.graphql" + +query($title: String!) { + blog(title: $title) { + description + mainAuthor { ...AuthorF } + posts { ...PostF } + } +} diff --git a/fixtures/lib/gitlab/graphql/queries/ee/author.fragment.graphql b/fixtures/lib/gitlab/graphql/queries/ee/author.fragment.graphql new file mode 100644 index 0000000000000000000000000000000000000000..884b683c5634e0afedf8eb42c241780274c9e21b --- /dev/null +++ b/fixtures/lib/gitlab/graphql/queries/ee/author.fragment.graphql @@ -0,0 +1,5 @@ +fragment AuthorF on Author { + name + handle + verified +} diff --git a/fixtures/lib/gitlab/graphql/queries/ee_else_ce.import.graphql b/fixtures/lib/gitlab/graphql/queries/ee_else_ce.import.graphql new file mode 100644 index 0000000000000000000000000000000000000000..5a4d0320eb629388981aacefeed7cce098cce83e --- /dev/null +++ b/fixtures/lib/gitlab/graphql/queries/ee_else_ce.import.graphql @@ -0,0 +1,9 @@ +#import "ee_else_ce/author.fragment.graphql" + +query { + post(slug: "validating-queries") { + title + content + author { ...AuthorF } + } +} diff --git a/fixtures/lib/gitlab/graphql/queries/missing_argument.graphql b/fixtures/lib/gitlab/graphql/queries/missing_argument.graphql new file mode 100644 index 0000000000000000000000000000000000000000..499495d9363a351ec1811c3399c8734d9fa1da9b --- /dev/null +++ b/fixtures/lib/gitlab/graphql/queries/missing_argument.graphql @@ -0,0 +1,8 @@ +query { + blog { + title + posts { + title + } + } +} diff --git a/fixtures/lib/gitlab/graphql/queries/mixed_client.query.graphql b/fixtures/lib/gitlab/graphql/queries/mixed_client.query.graphql new file mode 100644 index 0000000000000000000000000000000000000000..f98d070958c5cd0f7d84617c226daa3d11142f93 --- /dev/null +++ b/fixtures/lib/gitlab/graphql/queries/mixed_client.query.graphql @@ -0,0 +1,7 @@ +query { + thingy @client + post(slug: "validating-queries") { + title + otherThing @client + } +} diff --git a/fixtures/lib/gitlab/graphql/queries/mixed_client_invalid.query.graphql b/fixtures/lib/gitlab/graphql/queries/mixed_client_invalid.query.graphql new file mode 100644 index 0000000000000000000000000000000000000000..e97c133c5ca4a650b54f221ec8cbe27c07e8f7fd --- /dev/null +++ b/fixtures/lib/gitlab/graphql/queries/mixed_client_invalid.query.graphql @@ -0,0 +1,7 @@ +query { + thingy @client + post(slug: "validating-queries") { + titlz + otherThing @client + } +} diff --git a/fixtures/lib/gitlab/graphql/queries/mixed_client_skipped_argument.graphql b/fixtures/lib/gitlab/graphql/queries/mixed_client_skipped_argument.graphql new file mode 100644 index 0000000000000000000000000000000000000000..a54890085f19b84da0bbb8d227b788fe0a561ba9 --- /dev/null +++ b/fixtures/lib/gitlab/graphql/queries/mixed_client_skipped_argument.graphql @@ -0,0 +1,11 @@ +query($slug: String!, $foo: String) { + thingy(someArg: $foo) @client { + x + y + z + } + post(slug: $slug) { + title + otherThing @client + } +} diff --git a/fixtures/lib/gitlab/graphql/queries/mixed_client_unused_fragment.graphql b/fixtures/lib/gitlab/graphql/queries/mixed_client_unused_fragment.graphql new file mode 100644 index 0000000000000000000000000000000000000000..0f4e92319abad05b828b86aef10376d86d209ecd --- /dev/null +++ b/fixtures/lib/gitlab/graphql/queries/mixed_client_unused_fragment.graphql @@ -0,0 +1,11 @@ +#import "./thingy.fragment.graphql" + +query($slug: String!, $foo: String) { + thingy(someArg: $foo) @client { + ...ThingyF + } + post(slug: $slug) { + title + otherThing @client + } +} diff --git a/fixtures/lib/gitlab/graphql/queries/post.fragment.graphql b/fixtures/lib/gitlab/graphql/queries/post.fragment.graphql new file mode 100644 index 0000000000000000000000000000000000000000..0049964cfa5ad049730e92c8a7c5f77347f0f808 --- /dev/null +++ b/fixtures/lib/gitlab/graphql/queries/post.fragment.graphql @@ -0,0 +1,8 @@ +# import "./author.fragment.graphql" + +fragment PostF on Post { + name + title + content + author { ...AuthorF } +} diff --git a/fixtures/lib/gitlab/graphql/queries/post_by_slug.graphql b/fixtures/lib/gitlab/graphql/queries/post_by_slug.graphql new file mode 100644 index 0000000000000000000000000000000000000000..a7febc39e817bff472f71c4d43bfba8bbb29a9d8 --- /dev/null +++ b/fixtures/lib/gitlab/graphql/queries/post_by_slug.graphql @@ -0,0 +1,7 @@ +query { + post(slug: "validating-queries") { + title + content + author { name } + } +} diff --git a/fixtures/lib/gitlab/graphql/queries/post_by_slug.with_import.graphql b/fixtures/lib/gitlab/graphql/queries/post_by_slug.with_import.graphql new file mode 100644 index 0000000000000000000000000000000000000000..fef763c42833f2a7c7673548446eef123585db0d --- /dev/null +++ b/fixtures/lib/gitlab/graphql/queries/post_by_slug.with_import.graphql @@ -0,0 +1,9 @@ +#import "./author.fragment.graphql" + +query { + post(slug: "validating-queries") { + title + content + author { ...AuthorF } + } +} diff --git a/fixtures/lib/gitlab/graphql/queries/post_by_slug.with_import.misspelled.graphql b/fixtures/lib/gitlab/graphql/queries/post_by_slug.with_import.misspelled.graphql new file mode 100644 index 0000000000000000000000000000000000000000..4b205860e6eb829ae5cde103310a3b1edc72d66a --- /dev/null +++ b/fixtures/lib/gitlab/graphql/queries/post_by_slug.with_import.misspelled.graphql @@ -0,0 +1,9 @@ +# import "./auther.fragment.graphql" + +query { + post(slug: "validating-queries") { + title + content + author { ...AuthorF } + } +} diff --git a/fixtures/lib/gitlab/graphql/queries/syntax-error.graphql b/fixtures/lib/gitlab/graphql/queries/syntax-error.graphql new file mode 100644 index 0000000000000000000000000000000000000000..f7d2730951d30339b90453d70e5ad8e3ab43060f --- /dev/null +++ b/fixtures/lib/gitlab/graphql/queries/syntax-error.graphql @@ -0,0 +1,5 @@ +query } + blog(title: "boom") { + description + } +} diff --git a/fixtures/lib/gitlab/graphql/queries/thingy.fragment.graphql b/fixtures/lib/gitlab/graphql/queries/thingy.fragment.graphql new file mode 100644 index 0000000000000000000000000000000000000000..2f95d647eb3f92d20b707a162fbed12141fa95ec --- /dev/null +++ b/fixtures/lib/gitlab/graphql/queries/thingy.fragment.graphql @@ -0,0 +1,3 @@ +fragment ThingyF on Thingy { + x y z +} diff --git a/fixtures/lib/gitlab/graphql/queries/transitive_bad_import.fragment.graphql b/fixtures/lib/gitlab/graphql/queries/transitive_bad_import.fragment.graphql new file mode 100644 index 0000000000000000000000000000000000000000..1a0769279ef7bea9d32c3af15a85d00e9a449241 --- /dev/null +++ b/fixtures/lib/gitlab/graphql/queries/transitive_bad_import.fragment.graphql @@ -0,0 +1,6 @@ +# import "does-not-exist.graphql" + +fragment AuthorF on Author { + name + handle +} diff --git a/fixtures/lib/gitlab/graphql/queries/transitive_bad_import.graphql b/fixtures/lib/gitlab/graphql/queries/transitive_bad_import.graphql new file mode 100644 index 0000000000000000000000000000000000000000..6520fd8d412226b2cdb00748e53dfe7ce2c53015 --- /dev/null +++ b/fixtures/lib/gitlab/graphql/queries/transitive_bad_import.graphql @@ -0,0 +1,9 @@ +# import "./transitive_bad_import.fragment.graphql" + +query($slug: String!) { + post(slug: $slug) { + title + content + author { ...AuthorF } + } +} diff --git a/fixtures/lib/gitlab/graphql/queries/typedefs.graphql b/fixtures/lib/gitlab/graphql/queries/typedefs.graphql new file mode 100644 index 0000000000000000000000000000000000000000..e25298661fd2f35c67ba20a6da768107dc1189bd --- /dev/null +++ b/fixtures/lib/gitlab/graphql/queries/typedefs.graphql @@ -0,0 +1,3 @@ +type Author { + name: String +} diff --git a/fixtures/lib/gitlab/graphql/queries/unused_import.graphql b/fixtures/lib/gitlab/graphql/queries/unused_import.graphql new file mode 100644 index 0000000000000000000000000000000000000000..19e9de90e81cc46c77177ce4c4a7b74e130a86a1 --- /dev/null +++ b/fixtures/lib/gitlab/graphql/queries/unused_import.graphql @@ -0,0 +1,8 @@ +# import "./author.fragment.graphql" + +query($slug: String!) { + post(slug: $slug) { + title + content + } +} diff --git a/fixtures/lib/gitlab/graphql/queries/wrong_field.graphql b/fixtures/lib/gitlab/graphql/queries/wrong_field.graphql new file mode 100644 index 0000000000000000000000000000000000000000..7903854c84e905ae42494c019e6e81951f210c1e --- /dev/null +++ b/fixtures/lib/gitlab/graphql/queries/wrong_field.graphql @@ -0,0 +1,7 @@ +query { + blog(title: "A history of GraphQL") { + title + createdAt + categories { name } + } +} diff --git a/fixtures/lib/gitlab/graphql/queries/wrong_field.import.graphql b/fixtures/lib/gitlab/graphql/queries/wrong_field.import.graphql new file mode 100644 index 0000000000000000000000000000000000000000..534154e2877f5502b0adefdf230556b151994b4b --- /dev/null +++ b/fixtures/lib/gitlab/graphql/queries/wrong_field.import.graphql @@ -0,0 +1,7 @@ +# import "./bad.fragment.graphql" + +query($title: String!) { + blog(title: $title) { + ...BadF + } +} diff --git a/lib/gitlab/graphql/queries.rb b/lib/gitlab/graphql/queries.rb new file mode 100644 index 0000000000000000000000000000000000000000..de9717434909fc53ed9aef84a2e70f9a37cb3c04 --- /dev/null +++ b/lib/gitlab/graphql/queries.rb @@ -0,0 +1,286 @@ +# frozen_string_literal: true + +require 'find' + +module Gitlab + module Graphql + module Queries + IMPORT_RE = /^#\s*import "(?[^"]+)"$/m.freeze + EE_ELSE_CE = /^ee_else_ce/.freeze + HOME_RE = /^~/.freeze + HOME_EE = %r{^ee/}.freeze + DOTS_RE = %r{^(\.\./)+}.freeze + DOT_RE = %r{^\./}.freeze + IMPLICIT_ROOT = %r{^app/}.freeze + CONN_DIRECTIVE = /@connection\(key: "\w+"\)/.freeze + + class WrappedError + delegate :message, to: :@error + + def initialize(error) + @error = error + end + + def path + [] + end + end + + class FileNotFound + def initialize(file) + @file = file + end + + def message + "File not found: #{@file}" + end + + def path + [] + end + end + + # We need to re-write queries to remove all @client fields. Ideally we + # would do that as a source-to-source transformation of the AST, but doing it using a + # printer is much simpler. + class ClientFieldRedactor < GraphQL::Language::Printer + attr_reader :fields_printed, :skipped_arguments, :printed_arguments, :used_fragments + + def initialize(skips = true) + @skips = skips + @fields_printed = 0 + @in_operation = false + @skipped_arguments = [].to_set + @printed_arguments = [].to_set + @used_fragments = [].to_set + @skipped_fragments = [].to_set + @used_fragments = [].to_set + end + + def print_variable_identifier(variable_identifier) + @printed_arguments << variable_identifier.name + super + end + + def print_fragment_spread(fragment_spread, indent: "") + @used_fragments << fragment_spread.name + super + end + + def print_operation_definition(op, indent: "") + @in_operation = true + out = +"#{indent}#{op.operation_type}" + out << " #{op.name}" if op.name + + # Do these first, so that we detect any skipped arguments + dirs = print_directives(op.directives) + sels = print_selections(op.selections, indent: indent) + + # remove variable definitions only used in skipped (client) fields + vars = op.variables.reject do |v| + @skipped_arguments.include?(v.name) && !@printed_arguments.include?(v.name) + end + + if vars.any? + out << "(#{vars.map { |v| print_variable_definition(v) }.join(", ")})" + end + + out + dirs + sels + ensure + @in_operation = false + end + + def print_field(field, indent: '') + if skips? && field.directives.any? { |d| d.name == 'client' } + skipped = self.class.new(false) + + skipped.print_node(field) + @skipped_fragments |= skipped.used_fragments + @skipped_arguments |= skipped.printed_arguments + + return '' + end + + ret = super + + @fields_printed += 1 if @in_operation && ret != '' + + ret + end + + def print_fragment_definition(fragment_def, indent: "") + if skips? && @skipped_fragments.include?(fragment_def.name) && !@used_fragments.include?(fragment_def.name) + return '' + end + + super + end + + def skips? + @skips + end + end + + class Definition + attr_reader :file, :imports + + def initialize(path, fragments) + @file = path + @fragments = fragments + @imports = [] + @errors = [] + @ee_else_ce = [] + end + + def text(mode: :ce) + qs = [query] + all_imports(mode: mode).uniq.sort.map { |p| fragment(p).query } + t = qs.join("\n\n").gsub(/\n\n+/, "\n\n") + + return t unless /@client/.match?(t) + + doc = ::GraphQL.parse(t) + printer = ClientFieldRedactor.new + redacted = doc.dup.to_query_string(printer: printer) + + return redacted if printer.fields_printed > 0 + end + + def query + return @query if defined?(@query) + + # CONN_DIRECTIVEs are purely client-side constructs + @query = File.read(file).gsub(CONN_DIRECTIVE, '').gsub(IMPORT_RE) do + path = $~[:path] + + if EE_ELSE_CE.match?(path) + @ee_else_ce << path.gsub(EE_ELSE_CE, '') + else + @imports << fragment_path(path) + end + + '' + end + rescue Errno::ENOENT + @errors << FileNotFound.new(file) + @query = nil + end + + def all_imports(mode: :ce) + return [] if query.nil? + + home = mode == :ee ? @fragments.home_ee : @fragments.home + eithers = @ee_else_ce.map { |p| home + p } + + (imports + eithers).flat_map { |p| [p] + @fragments.get(p).all_imports(mode: mode) } + end + + def all_errors + return @errors.to_set if query.nil? + + paths = imports + @ee_else_ce.flat_map { |p| [@fragments.home + p, @fragments.home_ee + p] } + + paths.map { |p| fragment(p).all_errors }.reduce(@errors.to_set) { |a, b| a | b } + end + + def validate(schema) + return [:client_query, []] if query.present? && text.nil? + + errs = all_errors.presence || schema.validate(text) + if @ee_else_ce.present? + errs += schema.validate(text(mode: :ee)) + end + + [:validated, errs] + rescue ::GraphQL::ParseError => e + [:validated, [WrappedError.new(e)]] + end + + private + + def fragment(path) + @fragments.get(path) + end + + def fragment_path(import_path) + frag_path = import_path.gsub(HOME_RE, @fragments.home) + frag_path = frag_path.gsub(HOME_EE, @fragments.home_ee + '/') + frag_path = frag_path.gsub(DOT_RE) do + Pathname.new(file).parent.to_s + '/' + end + frag_path = frag_path.gsub(DOTS_RE) do |dots| + rel_dir(dots.split('/').count) + end + frag_path = frag_path.gsub(IMPLICIT_ROOT) do + (Rails.root / 'app').to_s + '/' + end + + frag_path + end + + def rel_dir(n_steps_up) + path = Pathname.new(file).parent + while n_steps_up > 0 + path = path.parent + n_steps_up -= 1 + end + + path.to_s + '/' + end + end + + class Fragments + def initialize(root, dir = 'app/assets/javascripts') + @root = root + @store = {} + @dir = dir + end + + def home + @home ||= (@root / @dir).to_s + end + + def home_ee + @home_ee ||= (@root / 'ee' / @dir).to_s + end + + def get(frag_path) + @store[frag_path] ||= Definition.new(frag_path, self) + end + end + + def self.find(root) + definitions = [] + + ::Find.find(root.to_s) do |path| + definitions << Definition.new(path, fragments) if query?(path) + end + + definitions + rescue Errno::ENOENT + [] # root does not exist + end + + def self.fragments + @fragments ||= Fragments.new(Rails.root) + end + + def self.all + ['.', 'ee'].flat_map do |prefix| + find(Rails.root / prefix / 'app/assets/javascripts') + end + end + + def self.known_failure?(path) + @known_failures ||= YAML.safe_load(File.read(Rails.root.join('config', 'known_invalid_graphql_queries.yml'))) + + @known_failures.fetch('filenames', []).any? { |known_failure| path.to_s.ends_with?(known_failure) } + end + + def self.query?(path) + path.ends_with?('.graphql') && + !path.ends_with?('.fragment.graphql') && + !path.ends_with?('typedefs.graphql') + end + end + end +end diff --git a/lib/tasks/gitlab/graphql.rake b/lib/tasks/gitlab/graphql.rake index 5a583183924f9034a2fe87f82fc57e225e0d7762..f708114c2269297da75d67fa607fe395e1897e9b 100644 --- a/lib/tasks/gitlab/graphql.rake +++ b/lib/tasks/gitlab/graphql.rake @@ -33,6 +33,44 @@ namespace :gitlab do ) namespace :graphql do + desc 'Gitlab | GraphQL | Validate queries' + task validate: [:environment, :enable_feature_flags] do |t, args| + queries = if args.to_a.present? + args.to_a.flat_map { |path| Gitlab::Graphql::Queries.find(path) } + else + Gitlab::Graphql::Queries.all + end + + failed = queries.flat_map do |defn| + summary, errs = defn.validate(GitlabSchema) + + case summary + when :client_query + warn("SKIP #{defn.file}: client query") + else + warn("OK #{defn.file}") if errs.empty? + errs.each do |err| + warn(<<~MSG) + ERROR #{defn.file}: #{err.message} (at #{err.path.join('.')}) + MSG + end + end + + errs.empty? ? [] : [defn.file] + end + + if failed.present? + format_output( + "#{failed.count} GraphQL #{'query'.pluralize(failed.count)} out of #{queries.count} failed validation:", + *failed.map do |name| + known_failure = Gitlab::Graphql::Queries.known_failure?(name) + "- #{name}" + (known_failure ? ' (known failure)' : '') + end + ) + abort unless failed.all? { |name| Gitlab::Graphql::Queries.known_failure?(name) } + end + end + desc 'GitLab | GraphQL | Generate GraphQL docs' task compile_docs: [:environment, :enable_feature_flags] do renderer = Gitlab::Graphql::Docs::Renderer.new(GitlabSchema.graphql_definition, render_options) @@ -78,11 +116,11 @@ def render_options } end -def format_output(str) +def format_output(*strs) heading = '#' * 10 puts heading puts '#' - puts "# #{str}" + strs.each { |str| puts "# #{str}" } puts '#' puts heading end diff --git a/scripts/frontend/prettier.js b/scripts/frontend/prettier.js index 8e9ecc2ba85f3217762406a570c2521b4d969560..f721e46f36bd378bf6f17b030227e2b8bee0129d 100644 --- a/scripts/frontend/prettier.js +++ b/scripts/frontend/prettier.js @@ -7,7 +7,7 @@ const matchExtensions = ['js', 'vue', 'graphql']; // This will improve glob performance by excluding certain directories. // The .prettierignore file will also be respected, but after the glob has executed. -const globIgnore = ['**/node_modules/**', 'vendor/**', 'public/**']; +const globIgnore = ['**/node_modules/**', 'vendor/**', 'public/**', 'fixtures/**']; const readFileAsync = (file, options) => new Promise((resolve, reject) => { diff --git a/spec/lib/gitlab/graphql/queries_spec.rb b/spec/lib/gitlab/graphql/queries_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..6e08a87523f782720e929841764fd2870c8988e3 --- /dev/null +++ b/spec/lib/gitlab/graphql/queries_spec.rb @@ -0,0 +1,343 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' +require "test_prof/recipes/rspec/let_it_be" + +RSpec.describe Gitlab::Graphql::Queries do + shared_examples 'a valid GraphQL query for the blog schema' do + it 'is valid' do + expect(subject.validate(schema).second).to be_empty + end + end + + shared_examples 'an invalid GraphQL query for the blog schema' do + it 'is invalid' do + expect(subject.validate(schema).second).to match errors + end + end + + # Toy schema to validate queries against + let_it_be(:schema) do + author = Class.new(GraphQL::Schema::Object) do + graphql_name 'Author' + field :name, GraphQL::STRING_TYPE, null: true + field :handle, GraphQL::STRING_TYPE, null: false + field :verified, GraphQL::BOOLEAN_TYPE, null: false + end + + post = Class.new(GraphQL::Schema::Object) do + graphql_name 'Post' + field :name, GraphQL::STRING_TYPE, null: false + field :title, GraphQL::STRING_TYPE, null: false + field :content, GraphQL::STRING_TYPE, null: true + field :author, author, null: false + end + author.field :posts, [post], null: false do + argument :blog_title, GraphQL::STRING_TYPE, required: false + end + + blog = Class.new(GraphQL::Schema::Object) do + graphql_name 'Blog' + field :title, GraphQL::STRING_TYPE, null: false + field :description, GraphQL::STRING_TYPE, null: false + field :main_author, author, null: false + field :posts, [post], null: false + field :post, post, null: true do + argument :slug, GraphQL::STRING_TYPE, required: true + end + end + + Class.new(GraphQL::Schema) do + query(Class.new(GraphQL::Schema::Object) do + graphql_name 'Query' + field :blog, blog, null: true do + argument :title, GraphQL::STRING_TYPE, required: true + end + field :post, post, null: true do + argument :slug, GraphQL::STRING_TYPE, required: true + end + end) + end + end + + let(:root) do + Rails.root / 'fixtures/lib/gitlab/graphql/queries' + end + + describe Gitlab::Graphql::Queries::Fragments do + subject { described_class.new(root) } + + it 'has the right home' do + expect(subject.home).to eq (root / 'app/assets/javascripts').to_s + end + + it 'has the right EE home' do + expect(subject.home_ee).to eq (root / 'ee/app/assets/javascripts').to_s + end + + it 'caches query definitions' do + fragment = subject.get('foo') + + expect(fragment).to be_a(::Gitlab::Graphql::Queries::Definition) + expect(subject.get('foo')).to be fragment + end + end + + describe '.all' do + it 'is the combination of finding queries in CE and EE' do + expect(described_class) + .to receive(:find).with(Rails.root / 'app/assets/javascripts').and_return([:ce]) + expect(described_class) + .to receive(:find).with(Rails.root / 'ee/app/assets/javascripts').and_return([:ee]) + + expect(described_class.all).to eq([:ce, :ee]) + end + end + + describe '.find' do + def definition_of(path) + be_a(::Gitlab::Graphql::Queries::Definition) + .and(have_attributes(file: path.to_s)) + end + + it 'find a single specific file' do + path = root / 'post_by_slug.graphql' + + expect(described_class.find(path)).to contain_exactly(definition_of(path)) + end + + it 'ignores files that do not exist' do + path = root / 'not_there.graphql' + + expect(described_class.find(path)).to be_empty + end + + it 'ignores fragments' do + path = root / 'author.fragment.graphql' + + expect(described_class.find(path)).to be_empty + end + + it 'ignores typedefs' do + path = root / 'typedefs.graphql' + + expect(described_class.find(path)).to be_empty + end + + it 'finds all query definitions under a root directory' do + found = described_class.find(root) + + expect(found).to include( + definition_of(root / 'post_by_slug.graphql'), + definition_of(root / 'post_by_slug.with_import.graphql'), + definition_of(root / 'post_by_slug.with_import.misspelled.graphql'), + definition_of(root / 'duplicate_imports.graphql'), + definition_of(root / 'deeply/nested/query.graphql') + ) + + expect(found).not_to include( + definition_of(root / 'typedefs.graphql'), + definition_of(root / 'author.fragment.graphql') + ) + end + end + + describe Gitlab::Graphql::Queries::Definition do + let(:fragments) { Gitlab::Graphql::Queries::Fragments.new(root, '.') } + + subject { described_class.new(root / path, fragments) } + + context 'a simple query' do + let(:path) { 'post_by_slug.graphql' } + + it_behaves_like 'a valid GraphQL query for the blog schema' + end + + context 'a query with an import' do + let(:path) { 'post_by_slug.with_import.graphql' } + + it_behaves_like 'a valid GraphQL query for the blog schema' + end + + context 'a query with duplicate imports' do + let(:path) { 'duplicate_imports.graphql' } + + it_behaves_like 'a valid GraphQL query for the blog schema' + end + + context 'a query importing from ee_else_ce' do + let(:path) { 'ee_else_ce.import.graphql' } + + it_behaves_like 'a valid GraphQL query for the blog schema' + + it 'can resolve the ee fields' do + expect(subject.text(mode: :ce)).not_to include('verified') + expect(subject.text(mode: :ee)).to include('verified') + end + end + + context 'a query refering to parent directories' do + let(:path) { 'deeply/nested/query.graphql' } + + it_behaves_like 'a valid GraphQL query for the blog schema' + end + + context 'a query refering to parent directories, incorrectly' do + let(:path) { 'deeply/nested/bad_import.graphql' } + + it_behaves_like 'an invalid GraphQL query for the blog schema' do + let(:errors) do + contain_exactly( + be_a(::Gitlab::Graphql::Queries::FileNotFound) + .and(have_attributes(message: include('deeply/author.fragment.graphql'))) + ) + end + end + end + + context 'a query with a broken import' do + let(:path) { 'post_by_slug.with_import.misspelled.graphql' } + + it_behaves_like 'an invalid GraphQL query for the blog schema' do + let(:errors) do + contain_exactly( + be_a(::Gitlab::Graphql::Queries::FileNotFound) + .and(have_attributes(message: include('auther.fragment.graphql'))) + ) + end + end + end + + context 'a query which imports a file with a broken import' do + let(:path) { 'transitive_bad_import.graphql' } + + it_behaves_like 'an invalid GraphQL query for the blog schema' do + let(:errors) do + contain_exactly( + be_a(::Gitlab::Graphql::Queries::FileNotFound) + .and(have_attributes(message: include('does-not-exist.graphql'))) + ) + end + end + end + + context 'a query containing a client directive' do + let(:path) { 'client.query.graphql' } + + it_behaves_like 'a valid GraphQL query for the blog schema' + + it 'is tagged as a client query' do + expect(subject.validate(schema).first).to eq :client_query + end + end + + context 'a mixed client query, valid' do + let(:path) { 'mixed_client.query.graphql' } + + it_behaves_like 'a valid GraphQL query for the blog schema' + + it 'is not tagged as a client query' do + expect(subject.validate(schema).first).not_to eq :client_query + end + end + + context 'a mixed client query, with skipped argument' do + let(:path) { 'mixed_client_skipped_argument.graphql' } + + it_behaves_like 'a valid GraphQL query for the blog schema' + end + + context 'a mixed client query, with unused fragment' do + let(:path) { 'mixed_client_unused_fragment.graphql' } + + it_behaves_like 'a valid GraphQL query for the blog schema' + end + + context 'a client query, with unused fragment' do + let(:path) { 'client_unused_fragment.graphql' } + + it_behaves_like 'a valid GraphQL query for the blog schema' + + it 'is tagged as a client query' do + expect(subject.validate(schema).first).to eq :client_query + end + end + + context 'a mixed client query, invalid' do + let(:path) { 'mixed_client_invalid.query.graphql' } + + it_behaves_like 'an invalid GraphQL query for the blog schema' do + let(:errors) do + contain_exactly(have_attributes(message: include('titlz'))) + end + end + end + + context 'a query containing a connection directive' do + let(:path) { 'connection.query.graphql' } + + it_behaves_like 'a valid GraphQL query for the blog schema' + end + + context 'a query which mentions an incorrect field' do + let(:path) { 'wrong_field.graphql' } + + it_behaves_like 'an invalid GraphQL query for the blog schema' do + let(:errors) do + contain_exactly( + have_attributes(message: /'createdAt' doesn't exist/), + have_attributes(message: /'categories' doesn't exist/) + ) + end + end + end + + context 'a query which has a missing argument' do + let(:path) { 'missing_argument.graphql' } + + it_behaves_like 'an invalid GraphQL query for the blog schema' do + let(:errors) do + contain_exactly( + have_attributes(message: include('blog')) + ) + end + end + end + + context 'a query which has a bad argument' do + let(:path) { 'bad_argument.graphql' } + + it_behaves_like 'an invalid GraphQL query for the blog schema' do + let(:errors) do + contain_exactly( + have_attributes(message: include('Nullability mismatch on variable $bad')) + ) + end + end + end + + context 'a query which has a syntax error' do + let(:path) { 'syntax-error.graphql' } + + it_behaves_like 'an invalid GraphQL query for the blog schema' do + let(:errors) do + contain_exactly( + have_attributes(message: include('Parse error')) + ) + end + end + end + + context 'a query which has an unused import' do + let(:path) { 'unused_import.graphql' } + + it_behaves_like 'an invalid GraphQL query for the blog schema' do + let(:errors) do + contain_exactly( + have_attributes(message: include('AuthorF was defined, but not used')) + ) + end + end + end + end +end