diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-04-22 15:09:49 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-04-22 15:09:49 +0300 |
commit | 4b074c5f634f8e1e550107f9e8237f07878ca0e8 (patch) | |
tree | 00afed4a6853548ec97203f3f807d954180b547d /lib | |
parent | b81fd57f3d62db4455108c8de4b8d7b8d403de35 (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'lib')
21 files changed, 573 insertions, 328 deletions
diff --git a/lib/banzai/filter/references/abstract_reference_filter.rb b/lib/banzai/filter/references/abstract_reference_filter.rb index 7109373dbce..2763e084de9 100644 --- a/lib/banzai/filter/references/abstract_reference_filter.rb +++ b/lib/banzai/filter/references/abstract_reference_filter.rb @@ -16,22 +16,9 @@ module Banzai REFERENCE_PLACEHOLDER = "_reference_#{SecureRandom.hex(16)}_" REFERENCE_PLACEHOLDER_PATTERN = %r{#{REFERENCE_PLACEHOLDER}(\d+)}.freeze - def self.object_class - # Implement in child class - # Example: MergeRequest - end - - def self.object_name - @object_name ||= object_class.name.underscore - end - - def self.object_sym - @object_sym ||= object_name.to_sym - end - # Public: Find references in text (like `!123` for merge requests) # - # AnyReferenceFilter.references_in(text) do |match, id, project_ref, matches| + # references_in(text) do |match, id, project_ref, matches| # object = find_object(project_ref, id) # "<a href=...>#{object.to_reference}</a>" # end @@ -42,7 +29,7 @@ module Banzai # of the external project reference, and all of the matchdata. # # Returns a String replaced with the return of the block. - def self.references_in(text, pattern = object_class.reference_pattern) + def references_in(text, pattern = object_class.reference_pattern) text.gsub(pattern) do |match| if ident = identifier($~) yield match, ident, $~[:project], $~[:namespace], $~ @@ -52,17 +39,13 @@ module Banzai end end - def self.identifier(match_data) + def identifier(match_data) symbol = symbol_from_match(match_data) parse_symbol(symbol, match_data) if object_class.reference_valid?(symbol) end - def identifier(match_data) - self.class.identifier(match_data) - end - - def self.symbol_from_match(match) + def symbol_from_match(match) key = object_sym match[key] if match.names.include?(key.to_s) end @@ -72,7 +55,7 @@ module Banzai # # This method has the contract that if a string `ref` refers to a # record `record`, then `parse_symbol(ref) == record_identifier(record)`. - def self.parse_symbol(symbol, match_data) + def parse_symbol(symbol, match_data) symbol.to_i end @@ -84,21 +67,10 @@ module Banzai record.id end - def object_class - self.class.object_class - end - - def object_sym - self.class.object_sym - end - - def references_in(*args, &block) - self.class.references_in(*args, &block) - end - # Implement in child class # Example: project.merge_requests.find def find_object(parent_object, id) + raise NotImplementedError, "#{self.class} must implement method: #{__callee__}" end # Override if the link reference pattern produces a different ID (global @@ -110,6 +82,7 @@ module Banzai # Implement in child class # Example: project_merge_request_url def url_for_object(object, parent_object) + raise NotImplementedError, "#{self.class} must implement method: #{__callee__}" end def find_object_cached(parent_object, id) @@ -139,7 +112,7 @@ module Banzai def call return doc unless project || group || user - ref_pattern = object_class.reference_pattern + ref_pattern = object_reference_pattern link_pattern = object_class.link_reference_pattern # Compile often used regexps only once outside of the loop @@ -425,14 +398,6 @@ module Banzai group_ref end - def unescape_html_entities(text) - CGI.unescapeHTML(text.to_s) - end - - def escape_html_entities(text) - CGI.escapeHTML(text.to_s) - end - def escape_with_placeholders(text, placeholder_data) escaped = escape_html_entities(text) diff --git a/lib/banzai/filter/references/alert_reference_filter.rb b/lib/banzai/filter/references/alert_reference_filter.rb index 90fef536605..512d4028520 100644 --- a/lib/banzai/filter/references/alert_reference_filter.rb +++ b/lib/banzai/filter/references/alert_reference_filter.rb @@ -5,12 +5,9 @@ module Banzai module References class AlertReferenceFilter < IssuableReferenceFilter self.reference_type = :alert + self.object_class = AlertManagement::Alert - def self.object_class - AlertManagement::Alert - end - - def self.object_sym + def object_sym :alert end diff --git a/lib/banzai/filter/references/commit_range_reference_filter.rb b/lib/banzai/filter/references/commit_range_reference_filter.rb index ad79f8a173c..df7f42eaa70 100644 --- a/lib/banzai/filter/references/commit_range_reference_filter.rb +++ b/lib/banzai/filter/references/commit_range_reference_filter.rb @@ -8,12 +8,9 @@ module Banzai # This filter supports cross-project references. class CommitRangeReferenceFilter < AbstractReferenceFilter self.reference_type = :commit_range + self.object_class = CommitRange - def self.object_class - CommitRange - end - - def self.references_in(text, pattern = CommitRange.reference_pattern) + def references_in(text, pattern = object_reference_pattern) text.gsub(pattern) do |match| yield match, $~[:commit_range], $~[:project], $~[:namespace], $~ end diff --git a/lib/banzai/filter/references/commit_reference_filter.rb b/lib/banzai/filter/references/commit_reference_filter.rb index 457921bd07d..1baafeccbd9 100644 --- a/lib/banzai/filter/references/commit_reference_filter.rb +++ b/lib/banzai/filter/references/commit_reference_filter.rb @@ -8,12 +8,9 @@ module Banzai # This filter supports cross-project references. class CommitReferenceFilter < AbstractReferenceFilter self.reference_type = :commit + self.object_class = Commit - def self.object_class - Commit - end - - def self.references_in(text, pattern = Commit.reference_pattern) + def references_in(text, pattern = object_reference_pattern) text.gsub(pattern) do |match| yield match, $~[:commit], $~[:project], $~[:namespace], $~ end @@ -39,7 +36,7 @@ module Banzai end # The default behaviour is `#to_i` - we just pass the hash through. - def self.parse_symbol(sha_hash, _match) + def parse_symbol(sha_hash, _match) sha_hash end diff --git a/lib/banzai/filter/references/design_reference_filter.rb b/lib/banzai/filter/references/design_reference_filter.rb index 61234e61c15..de8d58de72f 100644 --- a/lib/banzai/filter/references/design_reference_filter.rb +++ b/lib/banzai/filter/references/design_reference_filter.rb @@ -33,6 +33,7 @@ module Banzai end self.reference_type = :design + self.object_class = ::DesignManagement::Design def find_object(project, identifier) records_per_parent[project][identifier] @@ -76,15 +77,11 @@ module Banzai super.merge(issue: design.issue_id) end - def self.object_class - ::DesignManagement::Design - end - - def self.object_sym + def object_sym :design end - def self.parse_symbol(raw, match_data) + def parse_symbol(raw, match_data) filename = match_data[:url_filename] iid = match_data[:issue].to_i Identifier.new(filename: CGI.unescape(filename), issue_iid: iid) diff --git a/lib/banzai/filter/references/external_issue_reference_filter.rb b/lib/banzai/filter/references/external_issue_reference_filter.rb index 247e20967df..1061a9917dd 100644 --- a/lib/banzai/filter/references/external_issue_reference_filter.rb +++ b/lib/banzai/filter/references/external_issue_reference_filter.rb @@ -10,10 +10,11 @@ module Banzai # This filter does not support cross-project references. class ExternalIssueReferenceFilter < ReferenceFilter self.reference_type = :external_issue + self.object_class = ExternalIssue # Public: Find `JIRA-123` issue references in text # - # ExternalIssueReferenceFilter.references_in(text, pattern) do |match, issue| + # references_in(text, pattern) do |match, issue| # "<a href=...>##{issue}</a>" # end # @@ -22,7 +23,7 @@ module Banzai # Yields the String match and the String issue reference. # # Returns a String replaced with the return of the block. - def self.references_in(text, pattern) + def references_in(text, pattern = object_reference_pattern) text.gsub(pattern) do |match| yield match, $~[:issue] end @@ -32,27 +33,7 @@ module Banzai # Early return if the project isn't using an external tracker return doc if project.nil? || default_issues_tracker? - ref_pattern = issue_reference_pattern - ref_start_pattern = /\A#{ref_pattern}\z/ - - nodes.each_with_index do |node, index| - if text_node?(node) - replace_text_when_pattern_matches(node, index, ref_pattern) do |content| - issue_link_filter(content) - end - - elsif element_node?(node) - yield_valid_link(node) do |link, inner_html| - if link =~ ref_start_pattern - replace_link_node_with_href(node, index, link) do - issue_link_filter(link, link_content: inner_html) - end - end - end - end - end - - doc + super end private @@ -65,8 +46,8 @@ module Banzai # # Returns a String with `JIRA-123` references replaced with links. All # links have `gfm` and `gfm-issue` class names attached for styling. - def issue_link_filter(text, link_content: nil) - self.class.references_in(text, issue_reference_pattern) do |match, id| + def object_link_filter(text, pattern, link_content: nil, link_reference: false) + references_in(text) do |match, id| url = url_for_issue(id) klass = reference_class(:issue) data = data_attribute(project: project.id, external_issue: id) @@ -97,14 +78,10 @@ module Banzai external_issues_cached(:default_issues_tracker?) end - def issue_reference_pattern + def object_reference_pattern external_issues_cached(:external_issue_reference_pattern) end - def project - context[:project] - end - def issue_title "Issue in #{project.external_issue_tracker.title}" end diff --git a/lib/banzai/filter/references/feature_flag_reference_filter.rb b/lib/banzai/filter/references/feature_flag_reference_filter.rb index be9ded1ff43..0fb2b1b3b24 100644 --- a/lib/banzai/filter/references/feature_flag_reference_filter.rb +++ b/lib/banzai/filter/references/feature_flag_reference_filter.rb @@ -5,12 +5,9 @@ module Banzai module References class FeatureFlagReferenceFilter < IssuableReferenceFilter self.reference_type = :feature_flag + self.object_class = Operations::FeatureFlag - def self.object_class - Operations::FeatureFlag - end - - def self.object_sym + def object_sym :feature_flag end diff --git a/lib/banzai/filter/references/issue_reference_filter.rb b/lib/banzai/filter/references/issue_reference_filter.rb index eacf261b15f..1053501de7b 100644 --- a/lib/banzai/filter/references/issue_reference_filter.rb +++ b/lib/banzai/filter/references/issue_reference_filter.rb @@ -13,10 +13,7 @@ module Banzai # to reference issues from other GitLab projects. class IssueReferenceFilter < IssuableReferenceFilter self.reference_type = :issue - - def self.object_class - Issue - end + self.object_class = Issue def url_for_object(issue, project) return issue_path(issue, project) if only_path? diff --git a/lib/banzai/filter/references/iteration_reference_filter.rb b/lib/banzai/filter/references/iteration_reference_filter.rb index cf3d446147f..4d260983d98 100644 --- a/lib/banzai/filter/references/iteration_reference_filter.rb +++ b/lib/banzai/filter/references/iteration_reference_filter.rb @@ -6,10 +6,7 @@ module Banzai # The actual filter is implemented in the EE mixin class IterationReferenceFilter < AbstractReferenceFilter self.reference_type = :iteration - - def self.object_class - Iteration - end + self.object_class = Iteration end end end diff --git a/lib/banzai/filter/references/label_reference_filter.rb b/lib/banzai/filter/references/label_reference_filter.rb index a6a5eec5d9a..f9668d22d40 100644 --- a/lib/banzai/filter/references/label_reference_filter.rb +++ b/lib/banzai/filter/references/label_reference_filter.rb @@ -6,10 +6,7 @@ module Banzai # HTML filter that replaces label references with links. class LabelReferenceFilter < AbstractReferenceFilter self.reference_type = :label - - def self.object_class - Label - end + self.object_class = Label def find_object(parent_object, id) find_labels(parent_object).find(id) diff --git a/lib/banzai/filter/references/merge_request_reference_filter.rb b/lib/banzai/filter/references/merge_request_reference_filter.rb index 872c33f6873..6c5ad83d9ae 100644 --- a/lib/banzai/filter/references/merge_request_reference_filter.rb +++ b/lib/banzai/filter/references/merge_request_reference_filter.rb @@ -9,10 +9,7 @@ module Banzai # This filter supports cross-project references. class MergeRequestReferenceFilter < IssuableReferenceFilter self.reference_type = :merge_request - - def self.object_class - MergeRequest - end + self.object_class = MergeRequest def url_for_object(mr, project) h = Gitlab::Routing.url_helpers diff --git a/lib/banzai/filter/references/milestone_reference_filter.rb b/lib/banzai/filter/references/milestone_reference_filter.rb index 49110194ddc..c7e4b8b35a2 100644 --- a/lib/banzai/filter/references/milestone_reference_filter.rb +++ b/lib/banzai/filter/references/milestone_reference_filter.rb @@ -8,10 +8,7 @@ module Banzai include Gitlab::Utils::StrongMemoize self.reference_type = :milestone - - def self.object_class - Milestone - end + self.object_class = Milestone # Links to project milestones contain the IID, but when we're handling # 'regular' references, we need to use the global ID to disambiguate diff --git a/lib/banzai/filter/references/project_reference_filter.rb b/lib/banzai/filter/references/project_reference_filter.rb index 522c6e0f5f3..678d2aa3468 100644 --- a/lib/banzai/filter/references/project_reference_filter.rb +++ b/lib/banzai/filter/references/project_reference_filter.rb @@ -6,10 +6,11 @@ module Banzai # HTML filter that replaces project references with links. class ProjectReferenceFilter < ReferenceFilter self.reference_type = :project + self.object_class = Project # Public: Find `namespace/project>` project references in text # - # ProjectReferenceFilter.references_in(text) do |match, project| + # references_in(text) do |match, project| # "<a href=...>#{project}></a>" # end # @@ -18,33 +19,16 @@ module Banzai # Yields the String match, and the String project name. # # Returns a String replaced with the return of the block. - def self.references_in(text) - text.gsub(Project.markdown_reference_pattern) do |match| + def references_in(text, pattern = object_reference_pattern) + text.gsub(pattern) do |match| yield match, "#{$~[:namespace]}/#{$~[:project]}" end end - def call - ref_pattern = Project.markdown_reference_pattern - ref_pattern_start = /\A#{ref_pattern}\z/ - - nodes.each_with_index do |node, index| - if text_node?(node) - replace_text_when_pattern_matches(node, index, ref_pattern) do |content| - project_link_filter(content) - end - elsif element_node?(node) - yield_valid_link(node) do |link, inner_html| - if link =~ ref_pattern_start - replace_link_node_with_href(node, index, link) do - project_link_filter(link, link_content: inner_html) - end - end - end - end - end + private - doc + def object_reference_pattern + @object_reference_pattern ||= Project.markdown_reference_pattern end # Replace `namespace/project>` project references in text with links to the referenced @@ -55,8 +39,8 @@ module Banzai # # Returns a String with `namespace/project>` references replaced with links. All links # have `gfm` and `gfm-project` class names attached for styling. - def project_link_filter(text, link_content: nil) - self.class.references_in(text) do |match, project_path| + def object_link_filter(text, pattern, link_content: nil, link_reference: false) + references_in(text) do |match, project_path| cached_call(:banzai_url_for_object, match, path: [Project, project_path.downcase]) do if project = projects_hash[project_path.downcase] link_to_project(project, link_content: link_content) || match @@ -92,8 +76,6 @@ module Banzai refs.to_a end - private - def urls Gitlab::Routing.url_helpers end diff --git a/lib/banzai/filter/references/reference_filter.rb b/lib/banzai/filter/references/reference_filter.rb index dd15c43f5d8..a83cb12afd3 100644 --- a/lib/banzai/filter/references/reference_filter.rb +++ b/lib/banzai/filter/references/reference_filter.rb @@ -16,8 +16,14 @@ module Banzai include OutputSafety class << self + # Implement in child class + # Example: self.reference_type = :merge_request attr_accessor :reference_type + # Implement in child class + # Example: self.object_class = MergeRequest + attr_accessor :object_class + def call(doc, context = nil, result = nil) new(doc, context, result).call_and_update_nodes end @@ -34,6 +40,65 @@ module Banzai with_update_nodes { call } end + def call + ref_pattern_start = /\A#{object_reference_pattern}\z/ + + nodes.each_with_index do |node, index| + if text_node?(node) + replace_text_when_pattern_matches(node, index, object_reference_pattern) do |content| + object_link_filter(content, object_reference_pattern) + end + elsif element_node?(node) + yield_valid_link(node) do |link, inner_html| + if link =~ ref_pattern_start + replace_link_node_with_href(node, index, link) do + object_link_filter(link, object_reference_pattern, link_content: inner_html) + end + end + end + end + end + + doc + end + + # Public: Find references in text (like `!123` for merge requests) + # + # references_in(text) do |match, id, project_ref, matches| + # object = find_object(project_ref, id) + # "<a href=...>#{object.to_reference}</a>" + # end + # + # text - String text to search. + # + # Yields the String match, the Integer referenced object ID, an optional String + # of the external project reference, and all of the matchdata. + # + # Returns a String replaced with the return of the block. + def references_in(text, pattern = object_reference_pattern) + raise NotImplementedError, "#{self.class} must implement method: #{__callee__}" + end + + # Iterates over all <a> and text() nodes in a document. + # + # Nodes are skipped whenever their ancestor is one of the nodes returned + # by `ignore_ancestor_query`. Link tags are not processed if they have a + # "gfm" class or the "href" attribute is empty. + def each_node + return to_enum(__method__) unless block_given? + + doc.xpath(query).each do |node| + yield node + end + end + + # Returns an Array containing all HTML nodes. + def nodes + @nodes ||= each_node.to_a + end + + private + # Returns a data attribute String to attach to a reference link # # attributes - Hash, where the key becomes the data attribute name and the @@ -69,6 +134,13 @@ module Banzai end end + # Ensure that a :project key exists in context + # + # Note that while the key might exist, its value could be nil! + def validate + needs :project unless skip_project_check? + end + def project context[:project] end @@ -93,31 +165,6 @@ module Banzai "#{gfm_klass} has-tooltip" end - # Ensure that a :project key exists in context - # - # Note that while the key might exist, its value could be nil! - def validate - needs :project unless skip_project_check? - end - - # Iterates over all <a> and text() nodes in a document. - # - # Nodes are skipped whenever their ancestor is one of the nodes returned - # by `ignore_ancestor_query`. Link tags are not processed if they have a - # "gfm" class or the "href" attribute is empty. - def each_node - return to_enum(__method__) unless block_given? - - doc.xpath(query).each do |node| - yield node - end - end - - # Returns an Array containing all HTML nodes. - def nodes - @nodes ||= each_node.to_a - end - # Yields the link's URL and inner HTML whenever the node is a valid <a> tag. def yield_valid_link(node) link = unescape_link(node.attr('href').to_s) @@ -132,6 +179,14 @@ module Banzai CGI.unescape(href) end + def unescape_html_entities(text) + CGI.unescapeHTML(text.to_s) + end + + def escape_html_entities(text) + CGI.escapeHTML(text.to_s) + end + def replace_text_when_pattern_matches(node, index, pattern) return unless node.text =~ pattern @@ -161,7 +216,25 @@ module Banzai node.is_a?(Nokogiri::XML::Element) end - private + def object_class + self.class.object_class + end + + def object_reference_pattern + @object_reference_pattern ||= object_class.reference_pattern + end + + def object_name + @object_name ||= object_class.name.underscore + end + + def object_sym + @object_sym ||= object_name.to_sym + end + + def object_link_filter(text, pattern, link_content: nil, link_reference: false) + raise NotImplementedError, "#{self.class} must implement method: #{__callee__}" + end def query @query ||= %Q{descendant-or-self::text()[not(#{ignore_ancestor_query})] diff --git a/lib/banzai/filter/references/snippet_reference_filter.rb b/lib/banzai/filter/references/snippet_reference_filter.rb index bf7e0f78609..a2971e28de6 100644 --- a/lib/banzai/filter/references/snippet_reference_filter.rb +++ b/lib/banzai/filter/references/snippet_reference_filter.rb @@ -9,10 +9,7 @@ module Banzai # This filter supports cross-project references. class SnippetReferenceFilter < AbstractReferenceFilter self.reference_type = :snippet - - def self.object_class - Snippet - end + self.object_class = Snippet def find_object(project, id) return unless project.is_a?(Project) diff --git a/lib/banzai/filter/references/user_reference_filter.rb b/lib/banzai/filter/references/user_reference_filter.rb index 04665973f51..1709b607c2e 100644 --- a/lib/banzai/filter/references/user_reference_filter.rb +++ b/lib/banzai/filter/references/user_reference_filter.rb @@ -8,10 +8,11 @@ module Banzai # A special `@all` reference is also supported. class UserReferenceFilter < ReferenceFilter self.reference_type = :user + self.object_class = User # Public: Find `@user` user references in text # - # UserReferenceFilter.references_in(text) do |match, username| + # references_in(text) do |match, username| # "<a href=...>@#{user}</a>" # end # @@ -20,8 +21,8 @@ module Banzai # Yields the String match, and the String user name. # # Returns a String replaced with the return of the block. - def self.references_in(text) - text.gsub(User.reference_pattern) do |match| + def references_in(text, pattern = object_reference_pattern) + text.gsub(pattern) do |match| yield match, $~[:user] end end @@ -29,28 +30,11 @@ module Banzai def call return doc if project.nil? && group.nil? && !skip_project_check? - ref_pattern = User.reference_pattern - ref_pattern_start = /\A#{ref_pattern}\z/ - - nodes.each_with_index do |node, index| - if text_node?(node) - replace_text_when_pattern_matches(node, index, ref_pattern) do |content| - user_link_filter(content) - end - elsif element_node?(node) - yield_valid_link(node) do |link, inner_html| - if link =~ ref_pattern_start - replace_link_node_with_href(node, index, link) do - user_link_filter(link, link_content: inner_html) - end - end - end - end - end - - doc + super end + private + # Replace `@user` user references in text with links to the referenced # user's profile page. # @@ -59,8 +43,8 @@ module Banzai # # Returns a String with `@user` references replaced with links. All links # have `gfm` and `gfm-project_member` class names attached for styling. - def user_link_filter(text, link_content: nil) - self.class.references_in(text) do |match, username| + def object_link_filter(text, pattern, link_content: nil, link_reference: false) + references_in(text, pattern) do |match, username| if username == 'all' && !skip_project_check? link_to_all(link_content: link_content) else @@ -100,8 +84,6 @@ module Banzai refs.to_a end - private - def urls Gitlab::Routing.url_helpers end diff --git a/lib/banzai/filter/references/vulnerability_reference_filter.rb b/lib/banzai/filter/references/vulnerability_reference_filter.rb index e5f2408eda4..ce484667fc0 100644 --- a/lib/banzai/filter/references/vulnerability_reference_filter.rb +++ b/lib/banzai/filter/references/vulnerability_reference_filter.rb @@ -6,16 +6,7 @@ module Banzai # The actual filter is implemented in the EE mixin class VulnerabilityReferenceFilter < IssuableReferenceFilter self.reference_type = :vulnerability - - def self.object_class - Vulnerability - end - - private - - def project - context[:project] - end + self.object_class = Vulnerability end end end diff --git a/lib/gitlab/graphql/deprecation.rb b/lib/gitlab/graphql/deprecation.rb index e0176e2d6e0..8b73eeb4e52 100644 --- a/lib/gitlab/graphql/deprecation.rb +++ b/lib/gitlab/graphql/deprecation.rb @@ -41,7 +41,7 @@ module Gitlab parts = [ "#{deprecated_in(format: :markdown)}.", reason_text, - replacement.then { |r| "Use: `#{r}`." if r } + replacement.then { |r| "Use: [`#{r}`](##{r.downcase.tr('.', '')})." if r } ].compact case context diff --git a/lib/gitlab/graphql/docs/helper.rb b/lib/gitlab/graphql/docs/helper.rb index f4173e26224..ce5fb575b54 100644 --- a/lib/gitlab/graphql/docs/helper.rb +++ b/lib/gitlab/graphql/docs/helper.rb @@ -5,11 +5,52 @@ return if Rails.env.production? module Gitlab module Graphql module Docs + # We assume a few things about the schema. We use the graphql-ruby gem, which enforces: + # - All mutations have a single input field named 'input' + # - All mutations have a payload type, named after themselves + # - All mutations have an input type, named after themselves + # If these things change, then some of this code will break. Such places + # are guarded with an assertion that our assumptions are not violated. + ViolatedAssumption = Class.new(StandardError) + + SUGGESTED_ACTION = <<~MSG + We expect it to be impossible to violate our assumptions about + how mutation arguments work. + + If that is not the case, then something has probably changed in the + way we generate our schema, perhaps in the library we use: graphql-ruby + + Please ask for help in the #f_graphql or #backend channels. + MSG + + CONNECTION_ARGS = %w[after before first last].to_set + + FIELD_HEADER = <<~MD + #### Fields + + | Name | Type | Description | + | ---- | ---- | ----------- | + MD + + ARG_HEADER = <<~MD + # Arguments + + | Name | Type | Description | + | ---- | ---- | ----------- | + MD + + CONNECTION_NOTE = <<~MD + This field returns a [connection](#connections). It accepts the + four standard [pagination arguments](#connection-pagination-arguments): + `before: String`, `after: String`, `first: Int`, `last: Int`. + MD + # Helper with functions to be used by HAML templates # This includes graphql-docs gem helpers class. # You can check the included module on: https://github.com/gjtorikian/graphql-docs/blob/v1.6.0/lib/graphql-docs/helpers.rb module Helper include GraphQLDocs::Helpers + include Gitlab::Utils::StrongMemoize def auto_generated_comment <<-MD.strip_heredoc @@ -30,44 +71,52 @@ module Gitlab # Template methods: # Methods that return chunks of Markdown for insertion into the document - def render_name_and_description(object, owner: nil, level: 3) - content = [] + def render_full_field(field, heading_level: 3, owner: nil) + conn = connection?(field) + args = field[:arguments].reject { |arg| conn && CONNECTION_ARGS.include?(arg[:name]) } + arg_owner = [owner, field[:name]] + + chunks = [ + render_name_and_description(field, level: heading_level, owner: owner), + render_return_type(field), + render_input_type(field), + render_connection_note(field), + render_argument_table(heading_level, args, arg_owner), + render_return_fields(field, owner: owner) + ] + + join(:block, chunks) + end - content << "#{'#' * level} `#{object[:name]}`" + def render_argument_table(level, args, owner) + arg_header = ('#' * level) + ARG_HEADER + render_field_table(arg_header, args, owner) + end - if object[:description].present? - desc = object[:description].strip - desc += '.' unless desc.ends_with?('.') - end + def render_name_and_description(object, owner: nil, level: 3) + content = [] - if object[:is_deprecated] - owner = Array.wrap(owner) - deprecation = schema_deprecation(owner, object[:name]) - content << (deprecation&.original_description || desc) - content << render_deprecation(object, owner, :block) - else - content << desc - end + heading = '#' * level + name = [owner, object[:name]].compact.join('.') - content.compact.join("\n\n") - end + content << "#{heading} `#{name}`" + content << render_description(object, owner, :block) - def render_return_type(query) - "Returns #{render_field_type(query[:type])}.\n" + join(:block, content) end - def sorted_by_name(objects) - return [] unless objects.present? + def render_object_fields(fields, owner:, level_bump: 0) + return if fields.blank? - objects.sort_by { |o| o[:name] } - end + (with_args, no_args) = fields.partition { |f| args?(f) } + type_name = owner[:name] if owner + header_prefix = '#' * level_bump + sections = [ + render_simple_fields(no_args, type_name, header_prefix), + render_fields_with_arguments(with_args, type_name, header_prefix) + ] - def render_field(field, owner) - render_row( - render_name(field, owner), - render_field_type(field[:type]), - render_description(field, owner, :inline) - ) + join(:block, sections) end def render_enum_value(enum, value) @@ -82,104 +131,278 @@ module Gitlab # Methods that return parts of the schema, or related information: - # We are ignoring connections and built in types for now, - # they should be added when queries are generated. - def objects - object_types = graphql_object_types.select do |object_type| - !object_type[:name]["__"] - end + def connection_object_types + objects.select { |t| t[:is_edge] || t[:is_connection] } + end + + def object_types + objects.reject { |t| t[:is_edge] || t[:is_connection] || t[:is_payload] } + end + + def interfaces + graphql_interface_types.map { |t| t.merge(fields: t[:fields] + t[:connections]) } + end + + def fields_of(type_name) + graphql_operation_types + .find { |type| type[:name] == type_name } + .values_at(:fields, :connections) + .flatten + .then { |fields| sorted_by_name(fields) } + end + + # Place the arguments of the input types on the mutation itself. + # see: `#input_types` - this method must not call `#input_types` to avoid mutual recursion + def mutations + @mutations ||= sorted_by_name(graphql_mutation_types).map do |t| + inputs = t[:input_fields] + input = inputs.first + name = t[:name] + + assert!(inputs.one?, "Expected exactly 1 input field named #{name}. Found #{inputs.count} instead.") + assert!(input[:name] == 'input', "Expected the input of #{name} to be named 'input'") - object_types.each do |type| - type[:fields] += type[:connections] + input_type_name = input[:type][:name] + input_type = graphql_input_object_types.find { |t| t[:name] == input_type_name } + assert!(input_type.present?, "Cannot find #{input_type_name} for #{name}.input") + + arguments = input_type[:input_fields] + seen_type!(input_type_name) + t.merge(arguments: arguments) end end - def queries - graphql_operation_types.find { |type| type[:name] == 'Query' }.to_h.values_at(:fields, :connections).flatten + # We assume that the mutations have been processed first, marking their + # inputs as `seen_type?` + def input_types + mutations # ensure that mutations have seen their inputs first + graphql_input_object_types.reject { |t| seen_type?(t[:name]) } end - # We ignore the built-in enum types. + # We ignore the built-in enum types, and sort values by name def enums - graphql_enum_types.select do |enum_type| - !enum_type[:name].in?(%w[__DirectiveLocation __TypeKind]) - end + graphql_enum_types + .reject { |type| type[:values].empty? } + .reject { |enum_type| enum_type[:name].start_with?('__') } + .map { |type| type.merge(values: sorted_by_name(type[:values])) } end private # DO NOT CALL THESE METHODS IN TEMPLATES # Template methods + def render_return_type(query) + return unless query[:type] # for example, mutations + + "Returns #{render_field_type(query[:type])}." + end + + def render_simple_fields(fields, type_name, header_prefix) + render_field_table(header_prefix + FIELD_HEADER, fields, type_name) + end + + def render_fields_with_arguments(fields, type_name, header_prefix) + return if fields.empty? + + level = 5 + header_prefix.length + sections = sorted_by_name(fields).map do |f| + render_full_field(f, heading_level: level, owner: type_name) + end + + <<~MD.chomp + #{header_prefix}#### Fields with arguments + + #{join(:block, sections)} + MD + end + + def render_field_table(header, fields, owner) + return if fields.empty? + + fields = sorted_by_name(fields) + header + join(:table, fields.map { |f| render_field(f, owner) }) + end + + def render_field(field, owner) + render_row( + render_name(field, owner), + render_field_type(field[:type]), + render_description(field, owner, :inline) + ) + end + + def render_return_fields(mutation, owner:) + fields = mutation[:return_fields] + return if fields.blank? + + name = owner.to_s + mutation[:name] + render_object_fields(fields, owner: { name: name }) + end + + def render_connection_note(field) + return unless connection?(field) + + CONNECTION_NOTE.chomp + end + def render_row(*values) "| #{values.map { |val| val.to_s.squish }.join(' | ')} |" end def render_name(object, owner = nil) rendered_name = "`#{object[:name]}`" - rendered_name += ' **{warning-solid}**' if object[:is_deprecated] - rendered_name + rendered_name += ' **{warning-solid}**' if deprecated?(object, owner) + + return rendered_name unless owner + + owner = Array.wrap(owner).join('') + id = (owner + object[:name]).downcase + + %(<a id="#{id}"></a>) + rendered_name end # Returns the object description. If the object has been deprecated, # the deprecation reason will be returned in place of the description. def render_description(object, owner = nil, context = :block) - owner = Array.wrap(owner) - return render_deprecation(object, owner, context) if object[:is_deprecated] - return if object[:description].blank? + if deprecated?(object, owner) + render_deprecation(object, owner, context) + else + render_description_of(object) + end + end + + def deprecated?(object, owner) + return true if object[:is_deprecated] # only populated for fields, not arguments! + + key = [*Array.wrap(owner), object[:name]].join('.') + deprecations.key?(key) + end + + def render_description_of(object) + desc = if object[:is_edge] + base = object[:name].chomp('Edge') + "The edge type for [`#{base}`](##{base.downcase})." + elsif object[:is_connection] + base = object[:name].chomp('Connection') + "The connection type for [`#{base}`](##{base.downcase})." + else + object[:description]&.strip + end + + return if desc.blank? - desc = object[:description].strip desc += '.' unless desc.ends_with?('.') desc end def render_deprecation(object, owner, context) + buff = [] deprecation = schema_deprecation(owner, object[:name]) - return deprecation.markdown(context: context) if deprecation - reason = object[:deprecation_reason] || 'Use of this is deprecated.' - "**Deprecated:** #{reason}" + buff << (deprecation&.original_description || render_description_of(object)) if context == :block + buff << if deprecation + deprecation.markdown(context: context) + else + "**Deprecated:** #{object[:deprecation_reason]}" + end + + join(context, buff) end def render_field_type(type) "[`#{type[:info]}`](##{type[:name].downcase})" end + def join(context, chunks) + chunks.compact! + return if chunks.blank? + + case context + when :block + chunks.join("\n\n") + when :inline + chunks.join(" ").squish.presence + when :table + chunks.join("\n") + end + end + # Queries + def sorted_by_name(objects) + return [] unless objects.present? + + objects.sort_by { |o| o[:name] } + end + + def connection?(field) + type_name = field.dig(:type, :name) + type_name.present? && type_name.ends_with?('Connection') + end + + # We are ignoring connections and built in types for now, + # they should be added when queries are generated. + def objects + strong_memoize(:objects) do + mutations = schema.mutation&.fields&.keys&.to_set || [] + + graphql_object_types + .reject { |object_type| object_type[:name]["__"] } # We ignore introspection types. + .map do |type| + name = type[:name] + type.merge( + is_edge: name.ends_with?('Edge'), + is_connection: name.ends_with?('Connection'), + is_payload: name.ends_with?('Payload') && mutations.include?(name.chomp('Payload').camelcase(:lower)), + fields: type[:fields] + type[:connections] + ) + end + end + end + + def args?(field) + args = field[:arguments] + return false if args.blank? + return true unless connection?(field) + + args.any? { |arg| CONNECTION_ARGS.exclude?(arg[:name]) } + end + # returns the deprecation information for a field or argument # See: Gitlab::Graphql::Deprecation def schema_deprecation(type_name, field_name) - schema_member(type_name, field_name)&.deprecation - end - - # Return a part of the schema. - # - # This queries the Schema by owner and name to find: - # - # - fields (e.g. `schema_member('Query', 'currentUser')`) - # - arguments (e.g. `schema_member(['Query', 'project], 'fullPath')`) - def schema_member(type_name, field_name) - type_name = Array.wrap(type_name) - if type_name.size == 2 - arg_name = field_name - type_name, field_name = type_name - else - type_name = type_name.first - arg_name = nil - end + key = [*Array.wrap(type_name), field_name].join('.') + deprecations[key] + end - return if type_name.nil? || field_name.nil? + def render_input_type(query) + input_field = query[:input_fields]&.first + return unless input_field + + "Input type: `#{input_field[:type][:name]}`" + end - type = schema.types[type_name] - return unless type && type.kind.fields? + def deprecations + strong_memoize(:deprecations) do + mapping = {} - field = type.fields[field_name] - return field if arg_name.nil? + schema.types.each do |type_name, type| + next unless type.kind.fields? - args = field.arguments - is_mutation = field.mutation && field.mutation <= ::Mutations::BaseMutation - args = args['input'].type.unwrap.arguments if is_mutation + type.fields.each do |field_name, field| + mapping["#{type_name}.#{field_name}"] = field.try(:deprecation) + field.arguments.each do |arg_name, arg| + mapping["#{type_name}.#{field_name}.#{arg_name}"] = arg.try(:deprecation) + end + end + end + + mapping.compact + end + end - args[arg_name] + def assert!(claim, message) + raise ViolatedAssumption, "#{message}\n#{SUGGESTED_ACTION}" unless claim end end end diff --git a/lib/gitlab/graphql/docs/renderer.rb b/lib/gitlab/graphql/docs/renderer.rb index 497567f9389..ae0898e6198 100644 --- a/lib/gitlab/graphql/docs/renderer.rb +++ b/lib/gitlab/graphql/docs/renderer.rb @@ -24,6 +24,7 @@ module Gitlab @layout = Haml::Engine.new(File.read(template)) @parsed_schema = GraphQLDocs::Parser.new(schema.graphql_definition, {}).parse @schema = schema + @seen = Set.new end def contents @@ -37,6 +38,16 @@ module Gitlab FileUtils.mkdir_p(@output_dir) File.write(filename, contents) end + + private + + def seen_type?(name) + @seen.include?(name) + end + + def seen_type!(name) + @seen << name + end end end end diff --git a/lib/gitlab/graphql/docs/templates/default.md.haml b/lib/gitlab/graphql/docs/templates/default.md.haml index fe73297d0d9..57f4409d76f 100644 --- a/lib/gitlab/graphql/docs/templates/default.md.haml +++ b/lib/gitlab/graphql/docs/templates/default.md.haml @@ -26,17 +26,81 @@ The `Query` type contains the API's top-level entry points for all executable queries. \ -- sorted_by_name(queries).each do |query| - = render_name_and_description(query, owner: 'Query') - \ - = render_return_type(query) - - unless query[:arguments].empty? - ~ "#### Arguments\n" - ~ "| Name | Type | Description |" - ~ "| ---- | ---- | ----------- |" - - sorted_by_name(query[:arguments]).each do |argument| - = render_field(argument, query[:type][:name]) - \ +- fields_of('Query').each do |field| + = render_full_field(field, heading_level: 3, owner: 'Query') + \ + +:plain + ## `Mutation` type + + The `Mutation` type contains all the mutations you can execute. + + All mutations receive their arguments in a single input object named `input`, and all mutations + support at least a return field `errors` containing a list of error messages. + + All input objects may have a `clientMutationId: String` field, identifying the mutation. + + For example: + + ```graphql + mutation($id: NoteableID!, $body: String!) { + createNote(input: { noteableId: $id, body: $body }) { + errors + } + } + ``` +\ + +- mutations.each do |field| + = render_full_field(field, heading_level: 3, owner: 'Mutation') + \ + +:plain + ## Connections + + Some types in our schema are `Connection` types - they represent a paginated + collection of edges between two nodes in the graph. These follow the + [Relay cursor connections specification](https://relay.dev/graphql/connections.htm). + + ### Pagination arguments {#connection-pagination-arguments} + + All connection fields support the following pagination arguments: + + | Name | Type | Description | + |------|------|-------------| + | `after` | [`String`](#string) | Returns the elements in the list that come after the specified cursor. | + | `before` | [`String`](#string) | Returns the elements in the list that come before the specified cursor. | + | `first` | [`Int`](#int) | Returns the first _n_ elements from the list. | + | `last` | [`Int`](#int) | Returns the last _n_ elements from the list. | + + Since these arguments are common to all connection fields, they are not repeated for each connection. + + ### Connection fields + + All connections have at least the following fields: + + | Name | Type | Description | + |------|------|-------------| + | `pageInfo` | [`PageInfo!`](#pageinfo) | Pagination information. | + | `edges` | `[edge!]` | The edges. | + | `nodes` | `[item!]` | The items in the current page. | + + The precise type of `Edge` and `Item` depends on the kind of connection. A + [`UserConnection`](#userconnection) will have nodes that have the type + [`[User!]`](#user), and edges that have the type [`UserEdge`](#useredge). + + ### Connection types + + Some of the types in the schema exist solely to model connections. Each connection + has a distinct, named type, with a distinct named edge type. These are listed separately + below. +\ + +- connection_object_types.each do |type| + = render_name_and_description(type, level: 4) + \ + = render_object_fields(type[:fields], owner: type, level_bump: 1) + \ :plain ## Object types @@ -44,22 +108,20 @@ Object types represent the resources that the GitLab GraphQL API can return. They contain _fields_. Each field has its own type, which will either be one of the basic GraphQL [scalar types](https://graphql.org/learn/schema/#scalar-types) - (e.g.: `String` or `Boolean`) or other object types. + (e.g.: `String` or `Boolean`) or other object types. Fields may have arguments. + Fields with arguments are exactly like top-level queries, and are listed beneath + the table of fields for each object type. For more information, see [Object Types and Fields](https://graphql.org/learn/schema/#object-types-and-fields) on `graphql.org`. \ -- objects.each do |type| - - unless type[:fields].empty? - = render_name_and_description(type) - \ - ~ "| Field | Type | Description |" - ~ "| ----- | ---- | ----------- |" - - sorted_by_name(type[:fields]).each do |field| - = render_field(field, type[:name]) - \ +- object_types.each do |type| + = render_name_and_description(type) + \ + = render_object_fields(type[:fields], owner: type) + \ :plain ## Enumeration types @@ -73,14 +135,13 @@ \ - enums.each do |enum| - - unless enum[:values].empty? - = render_name_and_description(enum) - \ - ~ "| Value | Description |" - ~ "| ----- | ----------- |" - - sorted_by_name(enum[:values]).each do |value| - = render_enum_value(enum, value) - \ + = render_name_and_description(enum) + \ + ~ "| Value | Description |" + ~ "| ----- | ----------- |" + - enum[:values].each do |value| + = render_enum_value(enum, value) + \ :plain ## Scalar types @@ -133,7 +194,7 @@ ### Interfaces \ -- graphql_interface_types.each do |type| +- interfaces.each do |type| = render_name_and_description(type, level: 4) \ Implementations: @@ -141,8 +202,21 @@ - type[:implemented_by].each do |type_name| ~ "- [`#{type_name}`](##{type_name.downcase})" \ - ~ "| Field | Type | Description |" - ~ "| ----- | ---- | ----------- |" - - sorted_by_name(type[:fields] + type[:connections]).each do |field| - = render_field(field, type[:name]) + = render_object_fields(type[:fields], owner: type, level_bump: 1) + \ + +:plain + ## Input types + + Types that may be used as arguments (all scalar types may also + be used as arguments). + + Only general use input types are listed here. For mutation input types, + see the associated mutation type above. +\ + +- input_types.each do |type| + = render_name_and_description(type) + \ + = render_argument_table(3, type[:input_fields], type[:name]) \ |