diff options
Diffstat (limited to 'tooling')
-rw-r--r-- | tooling/graphql/docs/helper.rb | 438 | ||||
-rw-r--r-- | tooling/graphql/docs/renderer.rb | 54 | ||||
-rw-r--r-- | tooling/graphql/docs/templates/default.md.haml | 224 |
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]) + \ |