Render GFM html in GraphQL
This adds a `markdown_field` to our types. Using this helper will render a model's markdown field using the existing `MarkupHelper` with the context of the GraphQL query available to the helper. Having the context available to the helper is needed for redacting links to resources that the current user is not allowed to see. Because rendering the HTML can cause queries, the complexity of a these fields is raised by 5 above the default. The markdown field helper can be used as follows: ``` markdown_field :note_html, null: false ``` This would generate a field that will render the markdown field `note` of the model. This could be overridden by adding the `method:` argument. Passing a symbol for the method name: ``` markdown_field :body_html, null: false, method: :note ``` It will have this description by default: > The GitLab Flavored Markdown rendering of `note` This could be overridden by passing a `description:` argument. The type of a `markdown_field` is always `GraphQL::STRING_TYPE`.
This commit is contained in:
parent
adeccba136
commit
406808583c
19 changed files with 207 additions and 19 deletions
|
@ -4,6 +4,7 @@ module Types
|
|||
class BaseObject < GraphQL::Schema::Object
|
||||
prepend Gitlab::Graphql::Present
|
||||
prepend Gitlab::Graphql::ExposePermissions
|
||||
prepend Gitlab::Graphql::MarkdownField
|
||||
|
||||
field_class Types::BaseField
|
||||
|
||||
|
|
|
@ -14,7 +14,9 @@ module Types
|
|||
|
||||
field :iid, GraphQL::ID_TYPE, null: false
|
||||
field :title, GraphQL::STRING_TYPE, null: false
|
||||
markdown_field :title_html, null: true
|
||||
field :description, GraphQL::STRING_TYPE, null: true
|
||||
markdown_field :description_html, null: true
|
||||
field :state, IssueStateEnum, null: false
|
||||
|
||||
field :reference, GraphQL::STRING_TYPE, null: false, method: :to_reference do
|
||||
|
|
|
@ -5,6 +5,7 @@ module Types
|
|||
graphql_name 'Label'
|
||||
|
||||
field :description, GraphQL::STRING_TYPE, null: true
|
||||
markdown_field :description_html, null: true
|
||||
field :title, GraphQL::STRING_TYPE, null: false
|
||||
field :color, GraphQL::STRING_TYPE, null: false
|
||||
field :text_color, GraphQL::STRING_TYPE, null: false
|
||||
|
|
|
@ -15,7 +15,9 @@ module Types
|
|||
field :id, GraphQL::ID_TYPE, null: false
|
||||
field :iid, GraphQL::STRING_TYPE, null: false
|
||||
field :title, GraphQL::STRING_TYPE, null: false
|
||||
markdown_field :title_html, null: true
|
||||
field :description, GraphQL::STRING_TYPE, null: true
|
||||
markdown_field :description_html, null: true
|
||||
field :state, MergeRequestStateEnum, null: false
|
||||
field :created_at, Types::TimeType, null: false
|
||||
field :updated_at, Types::TimeType, null: false
|
||||
|
|
|
@ -12,6 +12,7 @@ module Types
|
|||
field :full_path, GraphQL::ID_TYPE, null: false
|
||||
|
||||
field :description, GraphQL::STRING_TYPE, null: true
|
||||
markdown_field :description_html, null: true
|
||||
field :visibility, GraphQL::STRING_TYPE, null: true
|
||||
field :lfs_enabled, GraphQL::BOOLEAN_TYPE, null: true, method: :lfs_enabled?
|
||||
field :request_access_enabled, GraphQL::BOOLEAN_TYPE, null: true
|
||||
|
|
|
@ -35,6 +35,8 @@ module Types
|
|||
method: :note,
|
||||
description: "The content note itself"
|
||||
|
||||
markdown_field :body_html, null: true, method: :note
|
||||
|
||||
field :created_at, Types::TimeType, null: false
|
||||
field :updated_at, Types::TimeType, null: false
|
||||
field :discussion, Types::Notes::DiscussionType, null: true, description: "The discussion this note is a part of"
|
||||
|
|
|
@ -17,6 +17,7 @@ module Types
|
|||
field :name, GraphQL::STRING_TYPE, null: false
|
||||
|
||||
field :description, GraphQL::STRING_TYPE, null: true
|
||||
markdown_field :description_html, null: true
|
||||
|
||||
field :tag_list, GraphQL::STRING_TYPE, null: true
|
||||
|
||||
|
|
|
@ -278,7 +278,7 @@ module MarkupHelper
|
|||
def prepare_for_rendering(html, context = {})
|
||||
return '' unless html.present?
|
||||
|
||||
context.merge!(
|
||||
context.reverse_merge!(
|
||||
current_user: (current_user if defined?(current_user)),
|
||||
|
||||
# RelativeLinkFilter
|
||||
|
|
5
changelogs/unreleased/bvl-markdown-graphql.yml
Normal file
5
changelogs/unreleased/bvl-markdown-graphql.yml
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Render GFM in GraphQL
|
||||
merge_request: 29700
|
||||
author:
|
||||
type: added
|
26
lib/gitlab/graphql/markdown_field.rb
Normal file
26
lib/gitlab/graphql/markdown_field.rb
Normal file
|
@ -0,0 +1,26 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Gitlab
|
||||
module Graphql
|
||||
module MarkdownField
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
prepended do
|
||||
def self.markdown_field(name, **kwargs)
|
||||
if kwargs[:resolver].present? || kwargs[:resolve].present?
|
||||
raise ArgumentError, 'Only `method` is allowed to specify the markdown field'
|
||||
end
|
||||
|
||||
method_name = kwargs.delete(:method) || name.to_s.sub(/_html$/, '')
|
||||
kwargs[:resolve] = Gitlab::Graphql::MarkdownField::Resolver.new(method_name.to_sym).proc
|
||||
|
||||
kwargs[:description] ||= "The GitLab Flavored Markdown rendering of `#{method_name}`"
|
||||
# Adding complexity to rendered notes since that could cause queries.
|
||||
kwargs[:complexity] ||= 5
|
||||
|
||||
field name, GraphQL::STRING_TYPE, **kwargs
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
22
lib/gitlab/graphql/markdown_field/resolver.rb
Normal file
22
lib/gitlab/graphql/markdown_field/resolver.rb
Normal file
|
@ -0,0 +1,22 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Gitlab
|
||||
module Graphql
|
||||
module MarkdownField
|
||||
class Resolver
|
||||
attr_reader :method_name
|
||||
|
||||
def initialize(method_name)
|
||||
@method_name = method_name
|
||||
end
|
||||
|
||||
def proc
|
||||
-> (object, _args, ctx) do
|
||||
# We need to `dup` the context so the MarkdownHelper doesn't modify it
|
||||
::MarkupHelper.markdown_field(object, method_name, ctx.to_h.dup)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -10,7 +10,10 @@ describe GitlabSchema.types['Issue'] do
|
|||
it { expect(described_class.interfaces).to include(Types::Notes::NoteableType.to_graphql) }
|
||||
|
||||
it 'has specific fields' do
|
||||
%i[relative_position web_path web_url reference].each do |field_name|
|
||||
fields = %i[title_html description_html relative_position web_path web_url
|
||||
reference]
|
||||
|
||||
fields.each do |field_name|
|
||||
expect(described_class).to have_graphql_field(field_name)
|
||||
end
|
||||
end
|
||||
|
|
10
spec/graphql/types/label_type_spec.rb
Normal file
10
spec/graphql/types/label_type_spec.rb
Normal file
|
@ -0,0 +1,10 @@
|
|||
# frozen_string_literal: true
|
||||
require 'spec_helper'
|
||||
|
||||
describe GitlabSchema.types['Label'] do
|
||||
it 'has the correct fields' do
|
||||
expected_fields = [:description, :description_html, :title, :color, :text_color]
|
||||
|
||||
is_expected.to have_graphql_fields(*expected_fields)
|
||||
end
|
||||
end
|
|
@ -7,7 +7,20 @@ describe GitlabSchema.types['MergeRequest'] do
|
|||
|
||||
it { expect(described_class.interfaces).to include(Types::Notes::NoteableType.to_graphql) }
|
||||
|
||||
describe 'nested head pipeline' do
|
||||
it { expect(described_class).to have_graphql_field(:head_pipeline) }
|
||||
it 'has the expected fields' do
|
||||
expected_fields = %w[
|
||||
notes discussions user_permissions id iid title title_html description
|
||||
description_html state created_at updated_at source_project target_project
|
||||
project project_id source_project_id target_project_id source_branch
|
||||
target_branch work_in_progress merge_when_pipeline_succeeds diff_head_sha
|
||||
merge_commit_sha user_notes_count should_remove_source_branch
|
||||
force_remove_source_branch merge_status in_progress_merge_commit_sha
|
||||
merge_error allow_collaboration should_be_rebased rebase_commit_sha
|
||||
rebase_in_progress merge_commit_message default_merge_commit_message
|
||||
merge_ongoing source_branch_exists mergeable_discussions_state web_url
|
||||
upvotes downvotes subscribed head_pipeline pipelines task_completion_status
|
||||
]
|
||||
|
||||
is_expected.to have_graphql_fields(*expected_fields)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -5,5 +5,12 @@ require 'spec_helper'
|
|||
describe GitlabSchema.types['Namespace'] do
|
||||
it { expect(described_class.graphql_name).to eq('Namespace') }
|
||||
|
||||
it { expect(described_class).to have_graphql_field(:projects) }
|
||||
it 'has the expected fields' do
|
||||
expected_fields = %w[
|
||||
id name path full_name full_path description description_html visibility
|
||||
lfs_enabled request_access_enabled projects
|
||||
]
|
||||
|
||||
is_expected.to have_graphql_fields(*expected_fields)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -5,7 +5,7 @@ describe GitlabSchema.types['Note'] do
|
|||
it 'exposes the expected fields' do
|
||||
expected_fields = [:id, :project, :author, :body, :created_at,
|
||||
:updated_at, :discussion, :resolvable, :position, :user_permissions,
|
||||
:resolved_by, :resolved_at, :system]
|
||||
:resolved_by, :resolved_at, :system, :body_html]
|
||||
|
||||
is_expected.to have_graphql_fields(*expected_fields)
|
||||
end
|
||||
|
|
|
@ -7,18 +7,22 @@ describe GitlabSchema.types['Project'] do
|
|||
|
||||
it { expect(described_class).to require_graphql_authorizations(:read_project) }
|
||||
|
||||
describe 'nested merge request' do
|
||||
it { expect(described_class).to have_graphql_field(:merge_requests) }
|
||||
it { expect(described_class).to have_graphql_field(:merge_request) }
|
||||
it 'has the expected fields' do
|
||||
expected_fields = %w[
|
||||
user_permissions id full_path path name_with_namespace
|
||||
name description description_html tag_list ssh_url_to_repo
|
||||
http_url_to_repo web_url star_count forks_count
|
||||
created_at last_activity_at archived visibility
|
||||
container_registry_enabled shared_runners_enabled
|
||||
lfs_enabled merge_requests_ff_only_enabled avatar_url
|
||||
issues_enabled merge_requests_enabled wiki_enabled
|
||||
snippets_enabled jobs_enabled public_jobs open_issues_count import_status
|
||||
only_allow_merge_if_pipeline_succeeds request_access_enabled
|
||||
only_allow_merge_if_all_discussions_are_resolved printing_merge_request_link_enabled
|
||||
namespace group statistics repository merge_requests merge_request issues
|
||||
issue pipelines
|
||||
]
|
||||
|
||||
is_expected.to have_graphql_fields(*expected_fields)
|
||||
end
|
||||
|
||||
describe 'nested issues' do
|
||||
it { expect(described_class).to have_graphql_field(:issues) }
|
||||
end
|
||||
|
||||
it { is_expected.to have_graphql_field(:pipelines) }
|
||||
|
||||
it { is_expected.to have_graphql_field(:repository) }
|
||||
|
||||
it { is_expected.to have_graphql_field(:statistics) }
|
||||
end
|
||||
|
|
33
spec/lib/gitlab/graphql/markdown_field/resolver_spec.rb
Normal file
33
spec/lib/gitlab/graphql/markdown_field/resolver_spec.rb
Normal file
|
@ -0,0 +1,33 @@
|
|||
# frozen_string_literal: true
|
||||
require 'spec_helper'
|
||||
|
||||
describe Gitlab::Graphql::MarkdownField::Resolver do
|
||||
include Gitlab::Routing
|
||||
let(:resolver) { described_class.new(:note) }
|
||||
|
||||
describe '#proc' do
|
||||
let(:project) { create(:project, :public) }
|
||||
let(:issue) { create(:issue, project: project) }
|
||||
let(:note) do
|
||||
create(:note,
|
||||
note: "Referencing #{issue.to_reference(full: true)}")
|
||||
end
|
||||
|
||||
it 'renders markdown correctly' do
|
||||
expect(resolver.proc.call(note, {}, {})).to include(issue_path(issue))
|
||||
end
|
||||
|
||||
context 'when the issue is not publicly accessible' do
|
||||
let(:project) { create(:project, :private) }
|
||||
|
||||
it 'hides the references from users that are not allowed to see the reference' do
|
||||
expect(resolver.proc.call(note, {}, {})).not_to include(issue_path(issue))
|
||||
end
|
||||
|
||||
it 'shows the reference to users that are allowed to see it' do
|
||||
expect(resolver.proc.call(note, {}, { current_user: project.owner }))
|
||||
.to include(issue_path(issue))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
55
spec/lib/gitlab/graphql/markdown_field_spec.rb
Normal file
55
spec/lib/gitlab/graphql/markdown_field_spec.rb
Normal file
|
@ -0,0 +1,55 @@
|
|||
# frozen_string_literal: true
|
||||
require 'spec_helper'
|
||||
|
||||
describe Gitlab::Graphql::MarkdownField do
|
||||
describe '.markdown_field' do
|
||||
it 'creates the field with some default attributes' do
|
||||
field = class_with_markdown_field(:test_html, null: true, method: :hello).fields['testHtml']
|
||||
|
||||
expect(field.name).to eq('testHtml')
|
||||
expect(field.description).to eq('The GitLab Flavored Markdown rendering of `hello`')
|
||||
expect(field.type).to eq(GraphQL::STRING_TYPE)
|
||||
expect(field.to_graphql.complexity).to eq(5)
|
||||
end
|
||||
|
||||
context 'developer warnings' do
|
||||
let(:expected_error) { /Only `method` is allowed to specify the markdown field/ }
|
||||
|
||||
it 'raises when passing a resolver' do
|
||||
expect { class_with_markdown_field(:test_html, null: true, resolver: 'not really') }
|
||||
.to raise_error(expected_error)
|
||||
end
|
||||
|
||||
it 'raises when passing a resolve block' do
|
||||
expect { class_with_markdown_field(:test_html, null: true, resolve: -> (_, _, _) { 'not really' } ) }
|
||||
.to raise_error(expected_error)
|
||||
end
|
||||
end
|
||||
|
||||
context 'resolving markdown' do
|
||||
let(:note) { build(:note, note: '# Markdown!') }
|
||||
let(:thing_with_markdown) { double('markdown thing', object: note) }
|
||||
let(:expected_markdown) { '<h1 data-sourcepos="1:1-1:11" dir="auto">Markdown!</h1>' }
|
||||
|
||||
it 'renders markdown from the same property as the field name without the `_html` suffix' do
|
||||
field = class_with_markdown_field(:note_html, null: false).fields['noteHtml']
|
||||
|
||||
expect(field.to_graphql.resolve(thing_with_markdown, {}, {})).to eq(expected_markdown)
|
||||
end
|
||||
|
||||
it 'renders markdown from a specific property when a `method` argument is passed' do
|
||||
field = class_with_markdown_field(:test_html, null: false, method: :note).fields['testHtml']
|
||||
|
||||
expect(field.to_graphql.resolve(thing_with_markdown, {}, {})).to eq(expected_markdown)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def class_with_markdown_field(name, **args)
|
||||
Class.new(GraphQL::Schema::Object) do
|
||||
prepend Gitlab::Graphql::MarkdownField
|
||||
|
||||
markdown_field name, **args
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Reference in a new issue