Commit ea3272d4 authored by Etienne Baqué's avatar Etienne Baqué

Merge branch 'ajk-linked-graphql-docs' into 'master'

Linked GraphQL docs output

See merge request gitlab-org/gitlab!55901
parents 587851e5 6512cd4d
...@@ -19,7 +19,14 @@ end ...@@ -19,7 +19,14 @@ end
module Types module Types
class GlobalIDType < BaseScalar class GlobalIDType < BaseScalar
graphql_name 'GlobalID' graphql_name 'GlobalID'
description 'A global identifier' description <<~DESC
A global identifier.
A global identifier represents an object uniquely across the application.
An example of such an identifier is "gid://gitlab/User/1".
Global identifiers are encoded as strings.
DESC
# @param value [GID] # @param value [GID]
# @return [String] # @return [String]
...@@ -46,38 +53,40 @@ module Types ...@@ -46,38 +53,40 @@ module Types
@id_types[model_class] ||= Class.new(self) do @id_types[model_class] ||= Class.new(self) do
graphql_name "#{model_class.name.gsub(/::/, '')}ID" graphql_name "#{model_class.name.gsub(/::/, '')}ID"
description "Identifier of #{model_class.name}." description <<~MD
A `#{graphql_name}` is a global ID. It is encoded as a string.
self.define_singleton_method(:to_s) do An example `#{graphql_name}` is: `"#{::Gitlab::GlobalId.build(model_name: model_class.name, id: 1)}"`.
MD
define_singleton_method(:to_s) do
graphql_name graphql_name
end end
self.define_singleton_method(:inspect) do define_singleton_method(:inspect) do
graphql_name graphql_name
end end
self.define_singleton_method(:coerce_result) do |gid, ctx| define_singleton_method(:coerce_result) do |gid, ctx|
global_id = ::Gitlab::GlobalId.as_global_id(gid, model_name: model_class.name) global_id = ::Gitlab::GlobalId.as_global_id(gid, model_name: model_class.name)
if suitable?(global_id) next global_id.to_s if suitable?(global_id)
global_id.to_s
else raise GraphQL::CoercionError, "Expected a #{model_class.name} ID, got #{global_id}"
raise GraphQL::CoercionError, "Expected a #{model_class.name} ID, got #{global_id}"
end
end end
self.define_singleton_method(:suitable?) do |gid| define_singleton_method(:suitable?) do |gid|
next false if gid.nil? next false if gid.nil?
gid.model_name.safe_constantize.present? && gid.model_name.safe_constantize.present? &&
gid.model_class.ancestors.include?(model_class) gid.model_class.ancestors.include?(model_class)
end end
self.define_singleton_method(:coerce_input) do |string, ctx| define_singleton_method(:coerce_input) do |string, ctx|
gid = super(string, ctx) gid = super(string, ctx)
raise GraphQL::CoercionError, "#{string.inspect} does not represent an instance of #{model_class.name}" unless suitable?(gid) next gid if suitable?(gid)
gid raise GraphQL::CoercionError, "#{string.inspect} does not represent an instance of #{model_class.name}"
end end
end end
end end
......
...@@ -3,7 +3,13 @@ ...@@ -3,7 +3,13 @@
module Types module Types
class TimeType < BaseScalar class TimeType < BaseScalar
graphql_name 'Time' graphql_name 'Time'
description 'Time represented in ISO 8601' description <<~DESC
Time represented in ISO 8601.
For example: "2021-03-09T14:58:50+00:00".
See `https://www.iso.org/iso-8601-date-and-time-format.html`.
DESC
def self.coerce_input(value, ctx) def self.coerce_input(value, ctx)
Time.parse(value) Time.parse(value)
......
---
title: Link fields to types in GraphQL reference documentation
merge_request: 55901
author:
type: changed
This diff is collapsed.
...@@ -27,16 +27,18 @@ module Gitlab ...@@ -27,16 +27,18 @@ module Gitlab
MD MD
end end
def render_name_and_description(object) def render_name_and_description(object, level = 3)
content = "### `#{object[:name]}`\n" content = []
content << "#{'#' * level} `#{object[:name]}`"
if object[:description].present? if object[:description].present?
content += "\n#{object[:description]}" desc = object[:description].strip
content += '.' unless object[:description].ends_with?('.') desc += '.' unless desc.ends_with?('.')
content += "\n" content << desc
end end
content content.join("\n\n")
end end
def sorted_by_name(objects) def sorted_by_name(objects)
...@@ -46,18 +48,15 @@ module Gitlab ...@@ -46,18 +48,15 @@ module Gitlab
end end
def render_field(field) def render_field(field)
'| %s | %s | %s |' % [ row(render_name(field), render_field_type(field[:type]), render_description(field))
render_name(field),
render_field_type(field[:type][:info]),
render_description(field)
]
end end
def render_enum_value(value) def render_enum_value(value)
'| %s | %s |' % [ row(render_name(value), render_description(value))
render_name(value), end
render_description(value)
] def row(*values)
"| #{values.join(' | ')} |"
end end
def render_name(object) def render_name(object)
...@@ -74,27 +73,19 @@ module Gitlab ...@@ -74,27 +73,19 @@ module Gitlab
"**Deprecated:** #{object[:deprecation_reason]}" "**Deprecated:** #{object[:deprecation_reason]}"
end end
# Some fields types are arrays of other types and are displayed
# on docs wrapped in square brackets, for example: [String!].
# This makes GitLab docs renderer thinks they are links so here
# we change them to be rendered as: String! => Array.
def render_field_type(type) def render_field_type(type)
array_type = type[/\[(.+)\]/, 1] "[`#{type[:info]}`](##{type[:name].downcase})"
end
if array_type def render_return_type(query)
"#{array_type} => Array" "Returns #{render_field_type(query[:type])}.\n"
else
type
end
end end
# We are ignoring connections and built in types for now, # We are ignoring connections and built in types for now,
# they should be added when queries are generated. # they should be added when queries are generated.
def objects def objects
object_types = graphql_object_types.select do |object_type| object_types = graphql_object_types.select do |object_type|
!object_type[:name]["Connection"] && !object_type[:name]["__"]
!object_type[:name]["Edge"] &&
!object_type[:name]["__"]
end end
object_types.each do |type| object_types.each do |type|
...@@ -109,7 +100,7 @@ module Gitlab ...@@ -109,7 +100,7 @@ module Gitlab
# We ignore the built-in enum types. # We ignore the built-in enum types.
def enums def enums
graphql_enum_types.select do |enum_type| graphql_enum_types.select do |enum_type|
!enum_type[:name].in?(%w(__DirectiveLocation __TypeKind)) !enum_type[:name].in?(%w[__DirectiveLocation __TypeKind])
end end
end end
end end
......
...@@ -28,6 +28,8 @@ ...@@ -28,6 +28,8 @@
- sorted_by_name(queries).each do |query| - sorted_by_name(queries).each do |query|
= render_name_and_description(query) = render_name_and_description(query)
\
= render_return_type(query)
- unless query[:arguments].empty? - unless query[:arguments].empty?
~ "#### Arguments\n" ~ "#### Arguments\n"
~ "| Name | Type | Description |" ~ "| Name | Type | Description |"
...@@ -52,6 +54,7 @@ ...@@ -52,6 +54,7 @@
- objects.each do |type| - objects.each do |type|
- unless type[:fields].empty? - unless type[:fields].empty?
= render_name_and_description(type) = render_name_and_description(type)
\
~ "| Field | Type | Description |" ~ "| Field | Type | Description |"
~ "| ----- | ---- | ----------- |" ~ "| ----- | ---- | ----------- |"
- sorted_by_name(type[:fields]).each do |field| - sorted_by_name(type[:fields]).each do |field|
...@@ -72,8 +75,74 @@ ...@@ -72,8 +75,74 @@
- enums.each do |enum| - enums.each do |enum|
- unless enum[:values].empty? - unless enum[:values].empty?
= render_name_and_description(enum) = render_name_and_description(enum)
\
~ "| Value | Description |" ~ "| Value | Description |"
~ "| ----- | ----------- |" ~ "| ----- | ----------- |"
- sorted_by_name(enum[:values]).each do |value| - sorted_by_name(enum[:values]).each do |value|
= render_enum_value(value) = render_enum_value(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, 4)
\
One of:
\
- type[:possible_types].each do |type_name|
~ "- [`#{type_name}`](##{type_name.downcase})"
\
:plain
### Interfaces
\
- graphql_interface_types.each do |type|
= render_name_and_description(type, 4)
\
Implementations:
\
- 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)
\
...@@ -15,10 +15,13 @@ RSpec.describe Gitlab::Graphql::Docs::Renderer do ...@@ -15,10 +15,13 @@ RSpec.describe Gitlab::Graphql::Docs::Renderer do
end end
end end
GraphQL::Schema.define(query: query_type) GraphQL::Schema.define(
query: query_type,
resolve_type: ->(obj, ctx) { raise 'Not a real schema' }
)
end end
let_it_be(:template) { Rails.root.join('lib/gitlab/graphql/docs/templates/', 'default.md.haml') } let_it_be(:template) { Rails.root.join('lib/gitlab/graphql/docs/templates/default.md.haml') }
let(:field_description) { 'List of objects.' } let(:field_description) { 'List of objects.' }
subject(:contents) do subject(:contents) do
...@@ -29,7 +32,23 @@ RSpec.describe Gitlab::Graphql::Docs::Renderer do ...@@ -29,7 +32,23 @@ RSpec.describe Gitlab::Graphql::Docs::Renderer do
).contents ).contents
end end
context 'A type with a field with a [Array] return type' do describe 'headings' do
let(:type) { ::GraphQL::INT_TYPE }
it 'contains the expected sections' do
expect(contents.lines.map(&:chomp)).to include(
'## `Query` type',
'## Object types',
'## Enumeration types',
'## Scalar types',
'## Abstract types',
'### Unions',
'### Interfaces'
)
end
end
context 'when a field has a list type' do
let(:type) do let(:type) do
Class.new(Types::BaseObject) do Class.new(Types::BaseObject) do
graphql_name 'ArrayTest' graphql_name 'ArrayTest'
...@@ -39,29 +58,33 @@ RSpec.describe Gitlab::Graphql::Docs::Renderer do ...@@ -39,29 +58,33 @@ RSpec.describe Gitlab::Graphql::Docs::Renderer do
end end
specify do specify do
type_name = '[String!]!'
inner_type = 'string'
expectation = <<~DOC expectation = <<~DOC
### `ArrayTest` ### `ArrayTest`
| Field | Type | Description | | Field | Type | Description |
| ----- | ---- | ----------- | | ----- | ---- | ----------- |
| `foo` | String! => Array | A description. | | `foo` | [`#{type_name}`](##{inner_type}) | A description. |
DOC DOC
is_expected.to include(expectation) is_expected.to include(expectation)
end end
context 'query generation' do describe 'a top level query field' do
let(:expectation) do let(:expectation) do
<<~DOC <<~DOC
### `foo` ### `foo`
List of objects. List of objects.
Returns [`ArrayTest`](#arraytest).
#### Arguments #### Arguments
| Name | Type | Description | | Name | Type | Description |
| ---- | ---- | ----------- | | ---- | ---- | ----------- |
| `id` | ID | ID of the object. | | `id` | [`ID`](#id) | ID of the object. |
DOC DOC
end end
...@@ -79,7 +102,7 @@ RSpec.describe Gitlab::Graphql::Docs::Renderer do ...@@ -79,7 +102,7 @@ RSpec.describe Gitlab::Graphql::Docs::Renderer do
end end
end end
context 'A type with fields defined in reverse alphabetical order' do describe 'when fields are not defined in alphabetical order' do
let(:type) do let(:type) do
Class.new(Types::BaseObject) do Class.new(Types::BaseObject) do
graphql_name 'OrderingTest' graphql_name 'OrderingTest'
...@@ -89,49 +112,56 @@ RSpec.describe Gitlab::Graphql::Docs::Renderer do ...@@ -89,49 +112,56 @@ RSpec.describe Gitlab::Graphql::Docs::Renderer do
end end
end end
specify do it 'lists the fields in alphabetical order' do
expectation = <<~DOC expectation = <<~DOC
### `OrderingTest` ### `OrderingTest`
| Field | Type | Description | | Field | Type | Description |
| ----- | ---- | ----------- | | ----- | ---- | ----------- |
| `bar` | String! | A description of bar field. | | `bar` | [`String!`](#string) | A description of bar field. |
| `foo` | String! | A description of foo field. | | `foo` | [`String!`](#string) | A description of foo field. |
DOC DOC
is_expected.to include(expectation) is_expected.to include(expectation)
end end
end end
context 'A type with a deprecated field' do context 'when a field is deprecated' do
let(:type) do let(:type) do
Class.new(Types::BaseObject) do Class.new(Types::BaseObject) do
graphql_name 'DeprecatedTest' graphql_name 'DeprecatedTest'
field :foo, GraphQL::STRING_TYPE, null: false, deprecated: { reason: 'This is deprecated', milestone: '1.10' }, description: 'A description.' field :foo,
type: GraphQL::STRING_TYPE,
null: false,
deprecated: { reason: 'This is deprecated', milestone: '1.10' },
description: 'A description.'
end end
end end
specify do it 'includes the deprecation' do
expectation = <<~DOC expectation = <<~DOC
### `DeprecatedTest` ### `DeprecatedTest`
| Field | Type | Description | | Field | Type | Description |
| ----- | ---- | ----------- | | ----- | ---- | ----------- |
| `foo` **{warning-solid}** | String! | **Deprecated:** This is deprecated. Deprecated in 1.10. | | `foo` **{warning-solid}** | [`String!`](#string) | **Deprecated:** This is deprecated. Deprecated in 1.10. |
DOC DOC
is_expected.to include(expectation) is_expected.to include(expectation)
end end
end end
context 'A type with an emum field' do context 'when a field has an Enumeration type' do
let(:type) do let(:type) do
enum_type = Class.new(Types::BaseEnum) do enum_type = Class.new(Types::BaseEnum) do
graphql_name 'MyEnum' graphql_name 'MyEnum'
value 'BAZ', description: 'A description of BAZ.' value 'BAZ',
value 'BAR', description: 'A description of BAR.', deprecated: { reason: 'This is deprecated', milestone: '1.10' } description: 'A description of BAZ.'
value 'BAR',
description: 'A description of BAR.',
deprecated: { reason: 'This is deprecated', milestone: '1.10' }
end end
Class.new(Types::BaseObject) do Class.new(Types::BaseObject) do
...@@ -141,7 +171,7 @@ RSpec.describe Gitlab::Graphql::Docs::Renderer do ...@@ -141,7 +171,7 @@ RSpec.describe Gitlab::Graphql::Docs::Renderer do
end end
end end
specify do it 'includes the description of the Enumeration' do
expectation = <<~DOC expectation = <<~DOC
### `MyEnum` ### `MyEnum`
...@@ -154,5 +184,129 @@ RSpec.describe Gitlab::Graphql::Docs::Renderer do ...@@ -154,5 +184,129 @@ RSpec.describe Gitlab::Graphql::Docs::Renderer do
is_expected.to include(expectation) is_expected.to include(expectation)
end end
end end
context 'when a field has a global ID type' do
let(:type) do
Class.new(Types::BaseObject) do
graphql_name 'IDTest'
description 'A test for rendering IDs.'
field :foo, ::Types::GlobalIDType[::User], null: true, description: 'A user foo.'
end
end
it 'includes the field and the description of the ID, so we can link to it' do
type_section = <<~DOC
### `IDTest`
A test for rendering IDs.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `foo` | [`UserID`](#userid) | A user foo. |
DOC
id_section = <<~DOC
### `UserID`
A `UserID` is a global ID. It is encoded as a string.
An example `UserID` is: `"gid://gitlab/User/1"`.
DOC
is_expected.to include(type_section, id_section)
end
end
context 'when there is an interface and a union' do
let(:type) do
user = Class.new(::Types::BaseObject)
user.graphql_name 'User'
user.field :user_field, ::GraphQL::STRING_TYPE, null: true
group = Class.new(::Types::BaseObject)
group.graphql_name 'Group'
group.field :group_field, ::GraphQL::STRING_TYPE, null: true
union = Class.new(::Types::BaseUnion)
union.graphql_name 'UserOrGroup'
union.description 'Either a user or a group.'
union.possible_types user, group
interface = Module.new
interface.include(::Types::BaseInterface)
interface.graphql_name 'Flying'
interface.description 'Something that can fly.'
interface.field :flight_speed, GraphQL::INT_TYPE, null: true, description: 'Speed in mph.'
african_swallow = Class.new(::Types::BaseObject)
african_swallow.graphql_name 'AfricanSwallow'
african_swallow.description 'A swallow from Africa.'
african_swallow.implements interface
interface.orphan_types african_swallow
Class.new(::Types::BaseObject) do
graphql_name 'AbstactTypeTest'
description 'A test for abstract types.'
field :foo, union, null: true, description: 'The foo.'
field :flying, interface, null: true, description: 'A flying thing.'
end
end
it 'lists the fields correctly, and includes descriptions of all the types' do
type_section = <<~DOC
### `AbstactTypeTest`
A test for abstract types.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `flying` | [`Flying`](#flying) | A flying thing. |
| `foo` | [`UserOrGroup`](#userorgroup) | The foo. |
DOC
union_section = <<~DOC
#### `UserOrGroup`
Either a user or a group.
One of:
- [`Group`](#group)
- [`User`](#user)
DOC
interface_section = <<~DOC
#### `Flying`
Something that can fly.
Implementations:
- [`AfricanSwallow`](#africanswallow)
| Field | Type | Description |
| ----- | ---- | ----------- |
| `flightSpeed` | [`Int`](#int) | Speed in mph. |
DOC
implementation_section = <<~DOC
### `AfricanSwallow`
A swallow from Africa.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `flightSpeed` | [`Int`](#int) | Speed in mph. |
DOC
is_expected.to include(
type_section,
union_section,
interface_section,
implementation_section
)
end
end
end end
end end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment