summaryrefslogtreecommitdiff
path: root/tooling
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-06-15 09:10:21 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2021-06-15 09:10:21 +0000
commita1498861799d1695682d72870580a6c3fb5ca3cf (patch)
tree9a8969fa32254a43b3c9d2cb31d5cc7d85eacdda /tooling
parentd35de87f96f580fede92e6b43352fbff8316e2c3 (diff)
downloadgitlab-ce-a1498861799d1695682d72870580a6c3fb5ca3cf.tar.gz
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'tooling')
-rw-r--r--tooling/graphql/docs/helper.rb438
-rw-r--r--tooling/graphql/docs/renderer.rb54
-rw-r--r--tooling/graphql/docs/templates/default.md.haml224
3 files changed, 716 insertions, 0 deletions
diff --git a/tooling/graphql/docs/helper.rb b/tooling/graphql/docs/helper.rb
new file mode 100644
index 00000000000..4a41930df46
--- /dev/null
+++ b/tooling/graphql/docs/helper.rb
@@ -0,0 +1,438 @@
+# 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](#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
+ ---
+ stage: Plan
+ group: Project Management
+ info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
+ ---
+
+ <!---
+ This documentation is auto generated by a script.
+
+ Please do not edit this file directly, check compile_docs task on 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])
+
+ buff << (deprecation&.original_description || render_description_of(object, owner)) 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]["__"] || 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
diff --git a/tooling/graphql/docs/renderer.rb b/tooling/graphql/docs/renderer.rb
new file mode 100644
index 00000000000..0c2e8cb3b86
--- /dev/null
+++ b/tooling/graphql/docs/renderer.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+require_relative 'helper'
+
+module Tooling
+ module Graphql
+ module Docs
+ # Gitlab renderer for graphql-docs.
+ # Uses HAML templates to parse markdown and generate .md files.
+ # It uses graphql-docs helpers and schema parser, more information in https://github.com/gjtorikian/graphql-docs.
+ #
+ # Arguments:
+ # schema - the GraphQL schema definition. For GitLab should be: GitlabSchema
+ # output_dir: The folder where the markdown files will be saved
+ # template: The path of the haml template to be parsed
+ class Renderer
+ include Tooling::Graphql::Docs::Helper
+
+ attr_reader :schema
+
+ def initialize(schema, output_dir:, template:)
+ @output_dir = output_dir
+ @template = template
+ @layout = Haml::Engine.new(File.read(template))
+ @parsed_schema = GraphQLDocs::Parser.new(schema.graphql_definition, {}).parse
+ @schema = schema
+ @seen = Set.new
+ end
+
+ def contents
+ # Render and remove an extra trailing new line
+ @contents ||= @layout.render(self).sub!(/\n(?=\Z)/, '')
+ end
+
+ def write
+ filename = File.join(@output_dir, 'index.md')
+
+ 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
+end
diff --git a/tooling/graphql/docs/templates/default.md.haml b/tooling/graphql/docs/templates/default.md.haml
new file mode 100644
index 00000000000..7d42fb3a9f8
--- /dev/null
+++ b/tooling/graphql/docs/templates/default.md.haml
@@ -0,0 +1,224 @@
+-# haml-lint:disable UnnecessaryStringOutput
+
+= auto_generated_comment
+
+:plain
+ # GraphQL API Resources
+
+ This documentation is self-generated based on GitLab current GraphQL schema.
+
+ The API can be explored interactively using the [GraphiQL IDE](../index.md#graphiql).
+
+ Each table below documents a GraphQL type. Types match loosely to models, but not all
+ fields and methods on a model are available via GraphQL.
+
+ WARNING:
+ Fields that are deprecated are marked with **{warning-solid}**.
+ Items (fields, enums, etc) that have been removed according to our [deprecation process](../index.md#deprecation-and-removal-process) can be found
+ in [Removed Items](../removed_items.md).
+
+ <!-- vale off -->
+ <!-- Docs linting disabled after this line. -->
+ <!-- See https://docs.gitlab.com/ee/development/documentation/testing.html#disable-vale-tests -->
+\
+
+:plain
+ ## `Query` type
+
+ The `Query` type contains the API's top-level entry points for all executable queries.
+\
+
+- 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
+ [`ProjectConnection`](#projectconnection) will have nodes that have the type
+ [`[Project!]`](#project), and edges that have the type [`ProjectEdge`](#projectedge).
+
+ ### 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
+
+ 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. 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`.
+\
+
+- object_types.each do |type|
+ = render_name_and_description(type)
+ \
+ = render_object_fields(type[:fields], owner: type)
+ \
+
+:plain
+ ## Enumeration types
+
+ Also called _Enums_, enumeration types are a special kind of scalar that
+ is restricted to a particular set of allowed values.
+
+ For more information, see
+ [Enumeration Types](https://graphql.org/learn/schema/#enumeration-types)
+ on `graphql.org`.
+\
+
+- enums.each do |enum|
+ = render_name_and_description(enum)
+ \
+ ~ "| Value | Description |"
+ ~ "| ----- | ----------- |"
+ - enum[:values].each do |value|
+ = render_enum_value(enum, value)
+ \
+
+:plain
+ ## Scalar types
+
+ Scalar values are atomic values, and do not have fields of their own.
+ Basic scalars include strings, boolean values, and numbers. This schema also
+ defines various custom scalar values, such as types for times and dates.
+
+ This schema includes custom scalar types for identifiers, with a specific type for
+ each kind of object.
+
+ For more information, read about [Scalar Types](https://graphql.org/learn/schema/#scalar-types) on `graphql.org`.
+\
+
+- graphql_scalar_types.each do |type|
+ = render_name_and_description(type)
+ \
+
+:plain
+ ## Abstract types
+
+ Abstract types (unions and interfaces) are ways the schema can represent
+ values that may be one of several concrete types.
+
+ - A [`Union`](https://graphql.org/learn/schema/#union-types) is a set of possible types.
+ The types might not have any fields in common.
+ - An [`Interface`](https://graphql.org/learn/schema/#interfaces) is a defined set of fields.
+ Types may `implement` an interface, which
+ guarantees that they have all the fields in the set. A type may implement more than
+ one interface.
+
+ See the [GraphQL documentation](https://graphql.org/learn/) for more information on using
+ abstract types.
+\
+
+:plain
+ ### Unions
+\
+
+- graphql_union_types.each do |type|
+ = render_name_and_description(type, level: 4)
+ \
+ One of:
+ \
+ - type[:possible_types].each do |member|
+ = render_union_member(member)
+ \
+
+:plain
+ ### Interfaces
+\
+
+- interfaces.each do |type|
+ = render_name_and_description(type, level: 4)
+ \
+ Implementations:
+ \
+ - type[:implemented_by].each do |type_name|
+ ~ "- [`#{type_name}`](##{type_name.downcase})"
+ \
+ = 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])
+ \