1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445
|
# frozen_string_literal: true
require 'gitlab/utils/strong_memoize'
module Tooling
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](#pagination-arguments):
`before: String`, `after: String`, `first: Int`, and `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
---
stage: Foundations
group: Import and Integrate
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://handbook.gitlab.com/handbook/product/ux/technical-writing/#assignments
---
<!--
This documentation is auto generated by a script.
Please do not edit this file directly.
To edit the introductory text, modify `tooling/graphql/docs/templates/default.md.haml`.
Run `bundle exec rake gitlab:graphql:compile_docs`
or check the `compile_docs` task in `lib/tasks/gitlab/graphql.rake`.
-->
MD
end
# Template methods:
# Methods that return chunks of Markdown for insertion into the document
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
def render_argument_table(level, args, owner)
arg_header = ('#' * level) + ARG_HEADER
render_field_table(arg_header, args, owner)
end
def render_name_and_description(object, owner: nil, level: 3)
content = []
heading = '#' * level
name = [owner, object[:name]].compact.join('.')
content << "#{heading} `#{name}`"
content << render_description(object, owner, :block)
join(:block, content)
end
def render_object_fields(fields, owner:, level_bump: 0)
return if fields.blank?
(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)
]
join(:block, sections)
end
def render_enum_value(enum, value)
render_row(render_name(value, enum[:name]), render_description(value, enum[:name], :inline))
end
def render_union_member(member)
"- [`#{member}`](##{member.downcase})"
end
# QUERIES:
# Methods that return parts of the schema, or related information:
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'")
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
# 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, and sort values by name
def enums
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 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)
if deprecated?(object, owner)
render_deprecation(object, owner, context)
else
render_description_of(object, owner, context)
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, owner, context = nil)
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 += '.' unless desc.ends_with?('.')
see = doc_reference(object, owner)
desc += " #{see}" if see
desc += " (see [Connections](#connections))" if connection?(object) && context != :block
desc
end
def doc_reference(object, owner)
field = schema_field(owner, object[:name]) if owner
return unless field
ref = field.try(:doc_reference)
return if ref.blank?
parts = ref.to_a.map do |(title, url)|
"[#{title.strip}](#{url.strip})"
end
"See #{parts.join(', ')}."
end
def render_deprecation(object, owner, context)
buff = []
deprecation = schema_deprecation(owner, object[:name])
original_description = deprecation&.original_description || render_description_of(object, owner)
buff << original_description if context == :block
buff << if deprecation
deprecation.markdown(context: context)
else
"**Deprecated:** #{object[:deprecation_reason]}"
end
buff << original_description if context == :inline && deprecation&.experiment?
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]["__"] || object_type[:name] == 'Subscription' } # We ignore introspection and subscription 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)
key = [*Array.wrap(type_name), field_name].join('.')
deprecations[key]
end
def render_input_type(query)
input_field = query[:input_fields]&.first
return unless input_field
"Input type: `#{input_field[:type][:name]}`"
end
def schema_field(type_name, field_name)
type = schema.types[type_name]
return unless type && type.kind.fields?
type.fields[field_name]
end
def deprecations
strong_memoize(:deprecations) do
mapping = {}
schema.types.each do |type_name, type|
if type.kind.fields?
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
elsif type.kind.enum?
type.values.each do |member_name, enum|
mapping["#{type_name}.#{member_name}"] = enum.try(:deprecation)
end
end
end
mapping.compact
end
end
def assert!(claim, message)
raise ViolatedAssumption, "#{message}\n#{SUGGESTED_ACTION}" unless claim
end
end
end
end
end
|