This is mainly the setup of mutations for GraphQL. Including authorization and basic return type-structure.
11 KiB
GraphQL API
Authentication
Authentication happens through the GraphqlController
, right now this
uses the same authentication as the Rails application. So the session
can be shared.
It is also possible to add a private_token
to the querystring, or
add a HTTP_PRIVATE_TOKEN
header.
Authorization
Fields can be authorized using the same abilities used in the Rails
app. This can be done using the authorize
helper:
module Types
class QueryType < BaseObject
graphql_name 'Query'
field :project, Types::ProjectType, null: true, resolver: Resolvers::ProjectResolver do
authorize :read_project
end
end
The object found by the resolve call is used for authorization.
This works for authorizing a single record, for authorizing collections, we should only load what the currently authenticated user is allowed to view. Preferably we use our existing finders for that.
Types
When exposing a model through the GraphQL API, we do so by creating a
new type in app/graphql/types
.
When exposing properties in a type, make sure to keep the logic inside the definition as minimal as possible. Instead, consider moving any logic into a presenter:
class Types::MergeRequestType < BaseObject
present_using MergeRequestPresenter
name 'MergeRequest'
end
An existing presenter could be used, but it is also possible to create a new presenter specifically for GraphQL.
The presenter is initialized using the object resolved by a field, and the context.
Connection Types
GraphQL uses cursor based pagination to expose collections of items. This provides the clients with a lot of flexibility while also allowing the backend to use different pagination models.
To expose a collection of resources we can use a connection type. This wraps the array with default pagination fields. For example a query for project-pipelines could look like this:
query($project_path: ID!) {
project(fullPath: $project_path) {
pipelines(first: 2) {
pageInfo {
hasNextPage
hasPreviousPage
}
edges {
cursor
node {
id
status
}
}
}
}
}
This would return the first 2 pipelines of a project and related pagination info., ordered by descending ID. The returned data would look like this:
{
"data": {
"project": {
"pipelines": {
"pageInfo": {
"hasNextPage": true,
"hasPreviousPage": false
},
"edges": [
{
"cursor": "Nzc=",
"node": {
"id": "77",
"status": "FAILED"
}
},
{
"cursor": "Njc=",
"node": {
"id": "67",
"status": "FAILED"
}
}
]
}
}
}
}
To get the next page, the cursor of the last known element could be passed:
query($project_path: ID!) {
project(fullPath: $project_path) {
pipelines(first: 2, after: "Njc=") {
pageInfo {
hasNextPage
hasPreviousPage
}
edges {
cursor
node {
id
status
}
}
}
}
}
Exposing permissions for a type
To expose permissions the current user has on a resource, you can call
the expose_permissions
passing in a separate type representing the
permissions for the resource.
For example:
module Types
class MergeRequestType < BaseObject
expose_permissions Types::MergeRequestPermissionsType
end
end
The permission type inherits from BasePermissionType
which includes
some helper methods, that allow exposing permissions as non-nullable
booleans:
class MergeRequestPermissionsType < BasePermissionType
present_using MergeRequestPresenter
graphql_name 'MergeRequestPermissions'
abilities :admin_merge_request, :update_merge_request, :create_note
ability_field :resolve_note,
description: 'Whether or not the user can resolve disussions on the merge request'
permission_field :push_to_source_branch, method: :can_push_to_source_branch?
end
permission_field
: Will act the same asgraphql-ruby
'sfield
method but setting a default description and type and making them non-nullable. These options can still be overridden by adding them as arguments.ability_field
: Expose an ability defined in our policies. This takes behaves the same way aspermission_field
and the same arguments can be overridden.abilities
: Allows exposing several abilities defined in our policies at once. The fields for these will all have be non-nullable booleans with a default description.
Resolvers
To find objects to display in a field, we can add resolvers to
app/graphql/resolvers
.
Arguments can be defined within the resolver, those arguments will be made available to the fields using the resolver.
We already have a FullPathLoader
that can be included in other
resolvers to quickly find Projects and Namespaces which will have a
lot of dependant objects.
To limit the amount of queries performed, we can use BatchLoader
.
Mutations
Mutations are used to change any stored values, or to trigger actions. In the same way a GET-request should not modify data, we cannot modify data in a regular GraphQL-query. We can however in a mutation.
Fields
In the most common situations, a mutation would return 2 fields:
- The resource being modified
- A list of errors explaining why the action could not be performed. If the mutation succeeded, this list would be empty.
By inheriting any new mutations from Mutations::BaseMutation
the
errors
field is automatically added. A clientMutationId
field is
also added, this can be used by the client to identify the result of a
single mutation when multiple are performed within a single request.
Building Mutations
Mutations live in app/graphql/mutations
ideally grouped per
resources they are mutating, similar to our services. They should
inherit Mutations::BaseMutation
. The fields defined on the mutation
will be returned as the result of the mutation.
Always provide a consistent GraphQL-name to the mutation, this name is
used to generate the input types and the field the mutation is mounted
on. The name should look like <Resource being modified><Mutation class name>
, for example the Mutations::MergeRequests::SetWip
mutation has GraphQL name MergeRequestSetWip
.
Arguments required by the mutation can be defined as arguments
required for a field. These will be wrapped up in an input type for
the mutation. For example, the Mutations::MergeRequests::SetWip
with GraphQL-name MergeRequestSetWip
defines these arguments:
argument :project_path, GraphQL::ID_TYPE,
required: true,
description: "The project the merge request to mutate is in"
argument :iid, GraphQL::ID_TYPE,
required: true,
description: "The iid of the merge request to mutate"
argument :wip,
GraphQL::BOOLEAN_TYPE,
required: false,
description: <<~DESC
Whether or not to set the merge request as a WIP.
If not passed, the value will be toggled.
DESC
This would automatically generate an input type called
MergeRequestSetWipInput
with the 3 arguments we specified and the
clientMutationId
.
These arguments are then passed to the resolve
method of a mutation
as keyword arguments. From here, we can call the service that will
modify the resource.
The resolve
method should then return a hash with the same field
names as defined on the mutation and an errors
array. For example,
the Mutations::MergeRequests::SetWip
defines a merge_request
field:
field :merge_request,
Types::MergeRequestType,
null: true,
description: "The merge request after mutation"
This means that the hash returned from resolve
in this mutation
should look like this:
{
# The merge request modified, this will be wrapped in the type
# defined on the field
merge_request: merge_request,
# An array if strings if the mutation failed after authorization
errors: merge_request.errors.full_messages
}
To make the mutation available it should be defined on the mutation
type that lives in graphql/types/mutation_types
. The
mount_mutation
helper method will define a field based on the
GraphQL-name of the mutation:
module Types
class MutationType < BaseObject
include Gitlab::Graphql::MountMutation
graphql_name "Mutation"
mount_mutation Mutations::MergeRequests::SetWip
end
end
Will generate a field called mergeRequestSetWip
that
Mutations::MergeRequests::SetWip
to be resolved.
Authorizing resources
To authorize resources inside a mutation, we can include the
Gitlab::Graphql::Authorize::AuthorizeResource
concern in the
mutation.
This allows us to provide the required abilities on the mutation like this:
module Mutations
module MergeRequests
class SetWip < Base
graphql_name 'MergeRequestSetWip'
authorize :update_merge_request
end
end
end
We can then call authorize!
in the resolve
method, passing in the resource we
want to validate the abilities for.
Alternatively, we can add a find_object
method that will load the
object on the mutation. This would allow you to use the
authorized_find!
and authorized_find!
helper methods.
When a user is not allowed to perform the action, or an object is not
found, we should raise a
Gitlab::Graphql::Errors::ResourceNotAvailable
error. Which will be
correctly rendered to the clients.
Testing
full stack tests for a graphql query or mutation live in
spec/requests/api/graphql
.
When adding a query, the a working graphql query
shared example can
be used to test if the query renders valid results.
Using the GraphqlHelpers#all_graphql_fields_for
-helper, a query
including all available fields can be constructed. This makes it easy
to add a test rendering all possible fields for a query.
To test GraphQL mutation requests, GraphqlHelpers
provides 2
helpers: graphql_mutation
which takes the name of the mutation, and
a hash with the input for the mutation. This will return a struct with
a mutation query, and prepared variables.
This struct can then be passed to the post_graphql_mutation
helper,
that will post the request with the correct params, like a GraphQL
client would do.
To access the response of a mutation, the graphql_mutation_response
helper is available.
Using these helpers, we can build specs like this:
let(:mutation) do
graphql_mutation(
:merge_request_set_wip,
project_path: 'gitlab-org/gitlab-ce',
iid: '1',
wip: true
)
end
it 'returns a successfull response' do
post_graphql_mutation(mutation, current_user: user)
expect(response).to have_gitlab_http_status(:success)
expect(graphql_mutation_response(:merge_request_set_wip)['errors']).to be_empty
end